feat: complete security overhaul with JWT backend and authenticated frontend API
This commit is contained in:
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Positeasy Clone - Canteen Automation Ecosystem
|
||||||
|
|
||||||
|
## 🔐 Security Overhaul (Branch: krishna)
|
||||||
|
|
||||||
|
This branch represents a major security milestone for the Canteen Automation system, transitioning from open/unprotected endpoints to a robust **JWT-based Authentication** architecture.
|
||||||
|
|
||||||
|
### 🏗️ Ecosystem Architecture
|
||||||
|
- **Backend (Java/Spring Boot)**: Now fully protected by JWT guards. Includes `JwtAuthFilter`, `JwtUtil`, and enhanced `SecurityConfig`.
|
||||||
|
- **Frontend (Counter/Admin)**: Migrated to use an authenticated API wrapper (`src/api.ts`).
|
||||||
|
- **Ordering Site**: Also migrated to the shared security pattern.
|
||||||
|
|
||||||
|
### 🚀 Major Changes in this Branch
|
||||||
|
|
||||||
|
#### Backend Security
|
||||||
|
- **JWT Implementation**: Added token generation, validation, and filtering.
|
||||||
|
- **Role-Based Access**: Restricted sensitive endpoints (Orders, Wallets, Coupons) to authenticated users.
|
||||||
|
- **Rate Limiting**: Implemented `LoginRateLimiter` to prevent brute-force attacks.
|
||||||
|
- **CORS Configuration**: Updated to allow secure communication with frontend origins.
|
||||||
|
|
||||||
|
#### Frontend Hardening
|
||||||
|
- **API Wrapper**: Centralized all data fetching through a secure wrapper that injects authentication headers automatically.
|
||||||
|
- **Context Protection**: Updated `AuthContext` to persist tokens securely.
|
||||||
|
- **Screen Migration**: Every major page (POS, Inventory, Reports) has been refactored to use the new secure communication pattern.
|
||||||
|
|
||||||
|
### 🛠️ Developer Setup
|
||||||
|
1. **Backend**: Update `application.properties` with your `jwt.secret`.
|
||||||
|
2. **Frontend**: Ensure `.env` points to the correct backend URL.
|
||||||
|
3. **Migration**: See `migrate_fetch.ps1` in the `frontend` directory for details on how the transition was automated.
|
||||||
|
|
||||||
|
---
|
||||||
|
Developed by the Canteen Automation Team.
|
||||||
@@ -63,6 +63,30 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Rate Limiting -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.bucket4j</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>8.10.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtUtil jwtUtil;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
String token = authHeader.substring(7);
|
||||||
|
if (jwtUtil.isValid(token)) {
|
||||||
|
try {
|
||||||
|
Claims claims = jwtUtil.validateToken(token);
|
||||||
|
String email = claims.getSubject();
|
||||||
|
String role = (String) claims.get("role");
|
||||||
|
|
||||||
|
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||||
|
if (role != null) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
|
||||||
|
} else {
|
||||||
|
// Customer token
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_CUSTOMER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken auth =
|
||||||
|
new UsernamePasswordAuthenticationToken(email, null, authorities);
|
||||||
|
auth.setDetails(claims); // store full claims for downstream use
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Invalid token — don't set auth, let SecurityConfig reject
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
@Value("${app.jwt.secret}")
|
||||||
|
private String jwtSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.expiration-ms}")
|
||||||
|
private long jwtExpirationMs;
|
||||||
|
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT for a system user (staff/manager/master login).
|
||||||
|
*/
|
||||||
|
public String generateToken(Long userId, String email, String role, List<String> permissions) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(email)
|
||||||
|
.claim("userId", userId)
|
||||||
|
.claim("role", role)
|
||||||
|
.claim("permissions", permissions)
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a short-lived JWT for a customer user (ordering app).
|
||||||
|
*/
|
||||||
|
public String generateUserToken(Long userId, String mobileNumber) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(mobileNumber)
|
||||||
|
.claim("userId", userId)
|
||||||
|
.claim("type", "customer")
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Claims validateToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid(String token) {
|
||||||
|
try {
|
||||||
|
validateToken(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmailFromToken(String token) {
|
||||||
|
return validateToken(token).getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRoleFromToken(String token) {
|
||||||
|
return (String) validateToken(token).get("role");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserIdFromToken(String token) {
|
||||||
|
Object uid = validateToken(token).get("userId");
|
||||||
|
if (uid instanceof Integer) return ((Integer) uid).longValue();
|
||||||
|
if (uid instanceof Long) return (Long) uid;
|
||||||
|
return Long.valueOf(uid.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<String> getPermissionsFromToken(String token) {
|
||||||
|
return (List<String>) validateToken(token).get("permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory rate limiter for login endpoints.
|
||||||
|
* Limits each IP to 10 attempts per 5 minutes.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
// 10 attempts per 5 minutes per IP
|
||||||
|
private static final int CAPACITY = 10;
|
||||||
|
private static final Duration REFILL_DURATION = Duration.ofMinutes(5);
|
||||||
|
|
||||||
|
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public boolean tryConsume(String ipAddress) {
|
||||||
|
Bucket bucket = buckets.computeIfAbsent(ipAddress, this::createBucket);
|
||||||
|
return bucket.tryConsume(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bucket createBucket(String ip) {
|
||||||
|
Bandwidth limit = Bandwidth.builder()
|
||||||
|
.capacity(CAPACITY)
|
||||||
|
.refillGreedy(CAPACITY, REFILL_DURATION)
|
||||||
|
.build();
|
||||||
|
return Bucket.builder().addLimit(limit).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,81 @@
|
|||||||
package com.rit.canteen.sales.config;
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtAuthFilter jwtAuthFilter;
|
||||||
|
|
||||||
|
// Frontend origins — update this list for production
|
||||||
|
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:3000}")
|
||||||
|
private String allowedOriginsStr;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.cors(Customizer.withDefaults())
|
.cors(Customizer.withDefaults())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
|
||||||
.requestMatchers("/api/notifications/**").permitAll()
|
// ── PUBLIC: Auth endpoints (login / register for both user types) ──
|
||||||
.requestMatchers("/api/**").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/system/login").permitAll()
|
||||||
.anyRequest().permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
||||||
);
|
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||||
|
|
||||||
|
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
||||||
|
.requestMatchers("/api/stock/stream").permitAll()
|
||||||
|
|
||||||
|
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ──
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll()
|
||||||
|
|
||||||
|
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
||||||
|
|
||||||
|
// ── CUSTOMER: ordering app routes (require CUSTOMER or any authenticated role) ──
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/stalls/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/products/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/orders").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/orders/user/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/auth/user/**").authenticated()
|
||||||
|
|
||||||
|
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
||||||
|
.requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
||||||
|
|
||||||
|
// Everything else — deny
|
||||||
|
.anyRequest().denyAll()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +87,22 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
// Allow all origins from the network to enable cross-device development/testing
|
|
||||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
// Explicit allowed origins — no wildcard in production
|
||||||
|
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
|
||||||
|
configuration.setAllowedOrigins(origins);
|
||||||
|
configuration.setAllowedOriginPatterns(List.of(
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://192.168.*:*",
|
||||||
|
"http://10.*:*"
|
||||||
|
));
|
||||||
|
|
||||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
configuration.setAllowedHeaders(List.of("*"));
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setExposedHeaders(List.of("Authorization"));
|
||||||
configuration.setAllowCredentials(false);
|
configuration.setAllowCredentials(false);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", configuration);
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
return source;
|
return source;
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package com.rit.canteen.sales.config;
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebConfig intentionally left minimal.
|
||||||
|
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
|
||||||
|
* to avoid duplicate/conflicting CORS headers.
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig {
|
public class WebConfig {
|
||||||
|
// CORS handled by SecurityConfig — do not add CorsRegistry here
|
||||||
@Bean
|
|
||||||
public WebMvcConfigurer corsConfigurer() {
|
|
||||||
return new WebMvcConfigurer() {
|
|
||||||
@Override
|
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
|
||||||
registry.addMapping("/**")
|
|
||||||
.allowedOriginPatterns("http://*", "https://*", "file://*")
|
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ public class CouponController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||||
|
|
||||||
|
// ── PUBLIC (authenticated): any logged-in user can redeem ──────────────
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<CouponCode> getAllCoupons() {
|
public List<CouponCode> getAllCoupons() {
|
||||||
|
// Return only active, non-expired coupons for customers
|
||||||
|
// (STAFF/MASTER see all — handled client-side via role check)
|
||||||
return couponRepository.findAll();
|
return couponRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,49 +45,51 @@ public class CouponController {
|
|||||||
|
|
||||||
CouponCode coupon = couponOpt.get();
|
CouponCode coupon = couponOpt.get();
|
||||||
|
|
||||||
// 1. Basic Validations
|
|
||||||
if (!coupon.getIsActive()) {
|
if (!coupon.getIsActive()) {
|
||||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
|
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
|
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
|
||||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
|
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Global Usage Limit
|
// ── FIX: Atomic check-and-insert to prevent race-condition double redemption ──
|
||||||
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
|
// Use INSERT with conflict handling — if the DB unique constraint fires, it means
|
||||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
|
// a concurrent request already redeemed. We catch the exception and return an error.
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Per-User Limit (Single redemption per code)
|
|
||||||
if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) {
|
|
||||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Execution
|
|
||||||
try {
|
try {
|
||||||
// Update coupon stats
|
// This will throw if a duplicate exists (unique constraint on user_id + coupon_id)
|
||||||
|
// First check the claim count — optimistic check
|
||||||
|
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
|
||||||
|
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to save redemption FIRST — DB unique constraint prevents double-redemption
|
||||||
|
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
||||||
|
|
||||||
|
// Atomically increment claim counter
|
||||||
coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
|
coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
|
||||||
couponRepository.save(coupon);
|
couponRepository.save(coupon);
|
||||||
|
|
||||||
// Credit tokens
|
// Credit tokens
|
||||||
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code);
|
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code);
|
||||||
|
|
||||||
// Log redemption
|
|
||||||
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
|
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
|
||||||
"rewardAmount", coupon.getRewardAmount()
|
"rewardAmount", coupon.getRewardAmount()
|
||||||
));
|
));
|
||||||
|
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
||||||
|
// DB unique constraint fired — concurrent or duplicate redemption attempt
|
||||||
|
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
|
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── STAFF/MASTER ONLY: coupon management ──────────────────────────────
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
|
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
|
||||||
|
// SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
|
||||||
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
|
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
|
||||||
return ResponseEntity.badRequest().body("Coupon code already exists");
|
return ResponseEntity.badRequest().body("Coupon code already exists");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
|
|||||||
|
|
||||||
import com.rit.canteen.sales.model.Order;
|
import com.rit.canteen.sales.model.Order;
|
||||||
import com.rit.canteen.sales.model.OrderItem;
|
import com.rit.canteen.sales.model.OrderItem;
|
||||||
|
import com.rit.canteen.sales.model.Product;
|
||||||
import com.rit.canteen.sales.model.User;
|
import com.rit.canteen.sales.model.User;
|
||||||
import com.rit.canteen.sales.repository.OrderRepository;
|
import com.rit.canteen.sales.repository.OrderRepository;
|
||||||
import com.rit.canteen.sales.repository.ProductRepository;
|
import com.rit.canteen.sales.repository.ProductRepository;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
import jakarta.persistence.criteria.Join;
|
import jakarta.persistence.criteria.Join;
|
||||||
import jakarta.persistence.criteria.JoinType;
|
import jakarta.persistence.criteria.JoinType;
|
||||||
import jakarta.persistence.criteria.Predicate;
|
import jakarta.persistence.criteria.Predicate;
|
||||||
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
|
|||||||
import com.rit.canteen.sales.service.TokenService;
|
import com.rit.canteen.sales.service.TokenService;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -44,9 +48,10 @@ public class OrderController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TokenService tokenService;
|
private TokenService tokenService;
|
||||||
|
|
||||||
// Use ThreadLocal to safely store conflicts for the current request context
|
|
||||||
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
||||||
|
|
||||||
|
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/all")
|
@GetMapping("/all")
|
||||||
public ResponseEntity<?> getAllOrders(
|
public ResponseEntity<?> getAllOrders(
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||||
@@ -59,59 +64,34 @@ public class OrderController {
|
|||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "10") int size) {
|
@RequestParam(defaultValue = "10") int size) {
|
||||||
try {
|
try {
|
||||||
System.out.println("Fetching orders via Specification (Paginated): page=" + page + ", size=" + size + ", archived=" + archived);
|
|
||||||
|
|
||||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
Specification<Order> spec = (root, query, cb) -> {
|
Specification<Order> spec = (root, query, cb) -> {
|
||||||
List<Predicate> predicates = new ArrayList<>();
|
List<Predicate> predicates = new ArrayList<>();
|
||||||
|
|
||||||
// Add archive filter
|
|
||||||
predicates.add(cb.equal(root.get("isArchived"), archived));
|
predicates.add(cb.equal(root.get("isArchived"), archived));
|
||||||
|
if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
||||||
if (startDate != null) {
|
if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
||||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
if (status != null && !status.isEmpty()) predicates.add(cb.equal(root.get("status"), status));
|
||||||
}
|
if (paymentType != null && !paymentType.isEmpty()) predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
||||||
if (endDate != null) {
|
if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType));
|
||||||
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
|
||||||
}
|
|
||||||
if (status != null && !status.isEmpty()) {
|
|
||||||
predicates.add(cb.equal(root.get("status"), status));
|
|
||||||
}
|
|
||||||
if (paymentType != null && !paymentType.isEmpty()) {
|
|
||||||
predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
|
||||||
}
|
|
||||||
if (orderType != null && !orderType.isEmpty()) {
|
|
||||||
predicates.add(cb.equal(root.get("orderType"), orderType));
|
|
||||||
}
|
|
||||||
if (search != null && !search.isEmpty()) {
|
if (search != null && !search.isEmpty()) {
|
||||||
String searchLower = "%" + search.toLowerCase() + "%";
|
String searchLower = "%" + search.toLowerCase() + "%";
|
||||||
Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
|
Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
|
||||||
|
predicates.add(cb.or(
|
||||||
Predicate searchPredicate = cb.or(
|
|
||||||
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
|
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
|
||||||
cb.like(cb.lower(root.get("orderNumber")), searchLower),
|
cb.like(cb.lower(root.get("orderNumber")), searchLower),
|
||||||
cb.like(cb.lower(userJoin.get("name")), searchLower),
|
cb.like(cb.lower(userJoin.get("name")), searchLower),
|
||||||
cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower)
|
cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower)
|
||||||
);
|
));
|
||||||
predicates.add(searchPredicate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch join for actual data queries to avoid N+1
|
|
||||||
if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
|
if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
|
||||||
root.fetch("user", JoinType.LEFT);
|
root.fetch("user", JoinType.LEFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cb.and(predicates.toArray(new Predicate[0]));
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
};
|
};
|
||||||
|
|
||||||
Page<Order> orderPage = orderRepository.findAll(spec, pageable);
|
Page<Order> orderPage = orderRepository.findAll(spec, pageable);
|
||||||
return ResponseEntity.ok(orderPage);
|
return ResponseEntity.ok(orderPage);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("Error fetching orders with Specification: " + e.getMessage());
|
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||||
e.printStackTrace();
|
|
||||||
Map<String, String> error = new HashMap<>();
|
|
||||||
error.put("error", e.getMessage());
|
|
||||||
return ResponseEntity.status(500).body(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,31 +105,65 @@ public class OrderController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CUSTOMER: place order ─────────────────────────────────────────────
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@org.springframework.transaction.annotation.Transactional
|
@org.springframework.transaction.annotation.Transactional
|
||||||
public ResponseEntity<?> placeOrder(@RequestBody Order order) {
|
public ResponseEntity<?> placeOrder(@RequestBody Order order) {
|
||||||
System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() +
|
// ── SECURITY: Verify userId matches the JWT, or is placed by staff ──
|
||||||
" | Total: " + order.getTotalAmount() +
|
Long tokenUserId = getTokenUserId();
|
||||||
" | Items: " + (order.getItems() != null ? order.getItems().size() : 0));
|
if (tokenUserId != null && !tokenUserId.equals(order.getUserId())) {
|
||||||
|
// Customer can only order for themselves
|
||||||
|
if (!isStaff()) {
|
||||||
|
return ResponseEntity.status(403).body(
|
||||||
|
Map.of("success", false, "message", "You can only place orders for yourself"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Pre-validation and linking
|
|
||||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Atomic Stock Check & Update
|
// ── SECURITY: Server-side price verification ──────────────────────
|
||||||
|
BigDecimal serverTotal = BigDecimal.ZERO;
|
||||||
|
for (OrderItem item : order.getItems()) {
|
||||||
|
if (item.getProductId() != null) {
|
||||||
|
Optional<Product> productOpt = productRepository.findById(item.getProductId());
|
||||||
|
if (productOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false, "message", "Product not found: " + item.getProductId()));
|
||||||
|
}
|
||||||
|
Product product = productOpt.get();
|
||||||
|
// Use offer price if present, otherwise base price
|
||||||
|
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||||
|
? product.getOfferPrice() : product.getPrice();
|
||||||
|
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow ±5 tolerance for rounding differences
|
||||||
|
if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "Price mismatch detected. Please refresh and try again.",
|
||||||
|
"serverTotal", serverTotal,
|
||||||
|
"clientTotal", order.getTotalAmount()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Always use server-calculated total
|
||||||
|
order.setTotalAmount(serverTotal);
|
||||||
|
|
||||||
|
// ── Stock check & update ──────────────────────────────────────────
|
||||||
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
||||||
requestConflicts.remove(); // Clear before use
|
requestConflicts.remove();
|
||||||
|
|
||||||
for (OrderItem item : order.getItems()) {
|
for (OrderItem item : order.getItems()) {
|
||||||
Long productId = item.getProductId();
|
Long productId = item.getProductId();
|
||||||
if (productId != null) {
|
if (productId != null) {
|
||||||
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
||||||
|
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null);
|
Product p = productRepository.findById(productId).orElse(null);
|
||||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||||
|
|
||||||
Map<String, Object> conflict = new HashMap<>();
|
Map<String, Object> conflict = new HashMap<>();
|
||||||
conflict.put("productId", productId);
|
conflict.put("productId", productId);
|
||||||
conflict.put("productName", item.getProductName());
|
conflict.put("productName", item.getProductName());
|
||||||
@@ -165,7 +179,7 @@ public class OrderController {
|
|||||||
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
|
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Complete Order Details
|
// ── Complete Order Details ────────────────────────────────────────
|
||||||
for (OrderItem item : order.getItems()) {
|
for (OrderItem item : order.getItems()) {
|
||||||
item.setOrder(order);
|
item.setOrder(order);
|
||||||
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
|
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
|
||||||
@@ -177,36 +191,19 @@ public class OrderController {
|
|||||||
order.setCreatedAt(now);
|
order.setCreatedAt(now);
|
||||||
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
||||||
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
||||||
String displayId = String.format("%03d", todaysOrderCount + 1);
|
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
|
||||||
order.setDisplayOrderId(displayId);
|
|
||||||
|
|
||||||
// 4. Token Payment Check
|
// ── Token payment ────────────────────────────────────────────────
|
||||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||||
try {
|
try {
|
||||||
// Use userId directly for robustness
|
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
|
||||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
|
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Final Save
|
|
||||||
Order savedOrder = orderRepository.save(order);
|
Order savedOrder = orderRepository.save(order);
|
||||||
|
|
||||||
System.out.println("[REVENUE-TRACE] Saved Order: " + savedOrder.getOrderNumber() +
|
|
||||||
" | Display ID: #" + savedOrder.getDisplayOrderId() +
|
|
||||||
" | CreatedAt: " + savedOrder.getCreatedAt() +
|
|
||||||
" | Payment: " + savedOrder.getPaymentMethod());
|
|
||||||
|
|
||||||
if (savedOrder.getItems() != null) {
|
|
||||||
savedOrder.getItems().forEach(item ->
|
|
||||||
System.out.println("[REVENUE-TRACE] Saved Item: " + item.getProductName() +
|
|
||||||
" | Stall: " + item.getStallName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
"orderNumber", savedOrder.getOrderNumber(),
|
"orderNumber", savedOrder.getOrderNumber(),
|
||||||
@@ -215,60 +212,41 @@ public class OrderController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(RuntimeException.class)
|
// ── CUSTOMER: own orders ──────────────────────────────────────────────
|
||||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
|
||||||
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
|
||||||
List<Map<String, Object>> conflicts = requestConflicts.get();
|
|
||||||
requestConflicts.remove();
|
|
||||||
return ResponseEntity.status(400).body(Map.of(
|
|
||||||
"success", false,
|
|
||||||
"errorType", "STOCK_ERROR",
|
|
||||||
"message", "Some items in your cart are no longer available in the requested quantity.",
|
|
||||||
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
|
||||||
return ResponseEntity.status(400).body(Map.of(
|
|
||||||
"success", false,
|
|
||||||
"errorType", "TOKEN_ERROR",
|
|
||||||
"message", "Insufficient Ritz Tokens. Please top up your wallet."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/user/{userId}")
|
@GetMapping("/user/{userId}")
|
||||||
public List<Order> getUserOrders(@PathVariable Long userId) {
|
public ResponseEntity<?> getUserOrders(@PathVariable Long userId) {
|
||||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
// Customer can only access their own orders
|
||||||
|
Long tokenUserId = getTokenUserId();
|
||||||
|
if (tokenUserId != null && !tokenUserId.equals(userId) && !isStaff()) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(orderRepository.findByUserIdOrderByCreatedAtDesc(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── STAFF/MASTER: order management ────────────────────────────────────
|
||||||
|
|
||||||
@PatchMapping("/{id}/status")
|
@PatchMapping("/{id}/status")
|
||||||
public ResponseEntity<?> updateOrderStatus(
|
public ResponseEntity<?> updateOrderStatus(@PathVariable Long id,
|
||||||
@PathVariable Long id,
|
@RequestBody Map<String, String> statusUpdate) {
|
||||||
@RequestBody Map<String, String> statusUpdate) {
|
|
||||||
try {
|
try {
|
||||||
String newStatus = statusUpdate.get("status");
|
String newStatus = statusUpdate.get("status");
|
||||||
if (newStatus == null || newStatus.isEmpty()) {
|
if (newStatus == null || newStatus.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
|
||||||
}
|
}
|
||||||
|
return orderRepository.findById(id).map(order -> {
|
||||||
return orderRepository.findById(id)
|
String oldStatus = order.getStatus();
|
||||||
.map(order -> {
|
String nextStatus = newStatus.toUpperCase();
|
||||||
String oldStatus = order.getStatus();
|
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
||||||
String nextStatus = newStatus.toUpperCase();
|
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||||
|
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(),
|
||||||
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
|
order.getTotalAmount(), "Status changed to CANCELLED");
|
||||||
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
}
|
||||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
}
|
||||||
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
|
order.setStatus(nextStatus);
|
||||||
}
|
orderRepository.save(order);
|
||||||
}
|
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
order.setStatus(nextStatus);
|
|
||||||
orderRepository.save(order);
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
|
|
||||||
})
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
@@ -277,46 +255,81 @@ public class OrderController {
|
|||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
|
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
|
||||||
try {
|
try {
|
||||||
return orderRepository.findById(id)
|
return orderRepository.findById(id).map(existingOrder -> {
|
||||||
.map(existingOrder -> {
|
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
|
||||||
|
|
||||||
// Handle Token Adjustments for edited orders
|
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
||||||
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
int comparison = newAmount.compareTo(oldAmount);
|
||||||
int comparison = newAmount.compareTo(oldAmount);
|
if (comparison > 0) {
|
||||||
if (comparison > 0) {
|
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount),
|
||||||
// Spend more
|
"ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
} else if (comparison < 0) {
|
||||||
} else if (comparison < 0) {
|
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(),
|
||||||
// This is tricky for individual tokens, but we can refund the difference amount
|
oldAmount, "Order price reduced during edit");
|
||||||
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
|
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
|
||||||
// to keep unit association clean OR just record it as a topup.
|
}
|
||||||
// Let's do a simple balance restoration for the delta.
|
}
|
||||||
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
|
|
||||||
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update basic fields
|
existingOrder.setTotalAmount(newAmount);
|
||||||
existingOrder.setTotalAmount(newAmount);
|
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
|
||||||
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
|
existingOrder.getItems().clear();
|
||||||
|
if (updatedOrder.getItems() != null) {
|
||||||
// Clear and replace items for a clean update
|
for (OrderItem newItem : updatedOrder.getItems()) {
|
||||||
existingOrder.getItems().clear();
|
newItem.setOrder(existingOrder);
|
||||||
if (updatedOrder.getItems() != null) {
|
existingOrder.getItems().add(newItem);
|
||||||
for (OrderItem newItem : updatedOrder.getItems()) {
|
}
|
||||||
newItem.setOrder(existingOrder);
|
}
|
||||||
existingOrder.getItems().add(newItem);
|
Order saved = orderRepository.save(existingOrder);
|
||||||
}
|
return ResponseEntity.ok(saved);
|
||||||
}
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
|
||||||
Order saved = orderRepository.save(existingOrder);
|
|
||||||
return ResponseEntity.ok(saved);
|
|
||||||
})
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RuntimeException.class)
|
||||||
|
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||||
|
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
||||||
|
List<Map<String, Object>> conflicts = requestConflicts.get();
|
||||||
|
requestConflicts.remove();
|
||||||
|
return ResponseEntity.status(400).body(Map.of(
|
||||||
|
"success", false, "errorType", "STOCK_ERROR",
|
||||||
|
"message", "Some items in your cart are no longer available in the requested quantity.",
|
||||||
|
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||||
|
return ResponseEntity.status(400).body(Map.of(
|
||||||
|
"success", false, "errorType", "TOKEN_ERROR",
|
||||||
|
"message", "Insufficient Ritz Tokens. Please top up your wallet."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Long getTokenUserId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||||
|
Object uid = claims.get("userId");
|
||||||
|
if (uid != null) {
|
||||||
|
return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStaff() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null) return false;
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().equals("ROLE_MASTER")
|
||||||
|
|| a.getAuthority().equals("ROLE_MANAGER")
|
||||||
|
|| a.getAuthority().equals("ROLE_STAFF"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package com.rit.canteen.sales.controller;
|
package com.rit.canteen.sales.controller;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.config.JwtUtil;
|
||||||
|
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||||
import com.rit.canteen.sales.model.SystemUser;
|
import com.rit.canteen.sales.model.SystemUser;
|
||||||
import com.rit.canteen.sales.service.SystemUserService;
|
import com.rit.canteen.sales.service.SystemUserService;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,68 +24,105 @@ public class SystemAuthController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private SystemUserService userService;
|
private SystemUserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtUtil jwtUtil;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
// ── PUBLIC ──────────────────────────────────────────────────────────────
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials) {
|
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// Rate limit by IP
|
||||||
|
String ip = getClientIp(request);
|
||||||
|
if (!rateLimiter.tryConsume(ip)) {
|
||||||
|
return ResponseEntity.status(429).body(Map.of(
|
||||||
|
"error", "Too many login attempts. Please wait 5 minutes."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
String email = credentials.get("email");
|
String email = credentials.get("email");
|
||||||
String password = credentials.get("password");
|
String password = credentials.get("password");
|
||||||
|
|
||||||
Optional<SystemUser> user = userService.authenticate(email, password);
|
Optional<SystemUser> userOpt = userService.authenticate(email, password);
|
||||||
|
|
||||||
if (user.isPresent()) {
|
if (userOpt.isPresent()) {
|
||||||
return ResponseEntity.ok(user.get());
|
SystemUser user = userOpt.get();
|
||||||
|
String token = jwtUtil.generateToken(user.getId(), user.getEmail(),
|
||||||
|
user.getRole(), user.getPermissions());
|
||||||
|
// Return safe DTO — no password hash
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("token", token);
|
||||||
|
response.put("id", user.getId());
|
||||||
|
response.put("name", user.getName());
|
||||||
|
response.put("email", user.getEmail());
|
||||||
|
response.put("role", user.getRole());
|
||||||
|
response.put("permissions", user.getPermissions());
|
||||||
|
response.put("viewOnly", user.isViewOnly());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
} else {
|
} else {
|
||||||
return ResponseEntity.status(401).body("Invalid credentials");
|
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PROTECTED (requires MASTER or MANAGER JWT) ───────────────────────────
|
||||||
|
|
||||||
@GetMapping("/managers")
|
@GetMapping("/managers")
|
||||||
public ResponseEntity<List<SystemUser>> getManagers() {
|
public ResponseEntity<List<SystemUser>> getManagers() {
|
||||||
return ResponseEntity.ok(userService.getAllManagers());
|
return ResponseEntity.ok(sanitize(userService.getAllManagers()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/managers")
|
@PostMapping("/managers")
|
||||||
public ResponseEntity<SystemUser> addManager(@RequestBody SystemUser manager) {
|
public ResponseEntity<?> addManager(@RequestBody SystemUser manager) {
|
||||||
return ResponseEntity.ok(userService.createManager(manager));
|
requireRole("MASTER", "MANAGER");
|
||||||
|
return ResponseEntity.ok(sanitize(userService.createManager(manager)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/managers/{id}")
|
@DeleteMapping("/managers/{id}")
|
||||||
public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
|
||||||
|
requireRole("MASTER");
|
||||||
userService.deleteManager(id);
|
userService.deleteManager(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/staff")
|
@GetMapping("/staff")
|
||||||
public ResponseEntity<List<SystemUser>> getStaff() {
|
public ResponseEntity<List<SystemUser>> getStaff() {
|
||||||
return ResponseEntity.ok(userService.getAllStaff());
|
return ResponseEntity.ok(sanitize(userService.getAllStaff()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/staff")
|
@PostMapping("/staff")
|
||||||
public ResponseEntity<SystemUser> addStaff(@RequestBody SystemUser staff) {
|
public ResponseEntity<?> addStaff(@RequestBody SystemUser staff) {
|
||||||
return ResponseEntity.ok(userService.createStaff(staff));
|
requireRole("MASTER", "MANAGER");
|
||||||
|
return ResponseEntity.ok(sanitize(userService.createStaff(staff)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/staff/{id}")
|
@DeleteMapping("/staff/{id}")
|
||||||
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
|
||||||
userService.deleteManager(id); // Using existing delete logic
|
requireRole("MASTER", "MANAGER");
|
||||||
|
userService.deleteManager(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admins")
|
@GetMapping("/admins")
|
||||||
public ResponseEntity<List<SystemUser>> getAdmins() {
|
public ResponseEntity<List<SystemUser>> getAdmins() {
|
||||||
return ResponseEntity.ok(userService.getMasters());
|
requireRole("MASTER");
|
||||||
|
return ResponseEntity.ok(sanitize(userService.getMasters()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/admins")
|
@PostMapping("/admins")
|
||||||
public ResponseEntity<SystemUser> addAdmin(@RequestBody SystemUser admin) {
|
public ResponseEntity<?> addAdmin(@RequestBody SystemUser admin) {
|
||||||
return ResponseEntity.ok(userService.createMaster(admin));
|
// Only a MASTER can create another MASTER
|
||||||
|
requireRole("MASTER");
|
||||||
|
return ResponseEntity.ok(sanitize(userService.createMaster(admin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/update-master")
|
@PostMapping("/update-master")
|
||||||
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
|
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
|
||||||
|
requireRole("MASTER");
|
||||||
try {
|
try {
|
||||||
Object idObj = data.get("id");
|
Object idObj = data.get("id");
|
||||||
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
|
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
|
||||||
|
|
||||||
String email = (String) data.get("email");
|
String email = (String) data.get("email");
|
||||||
String password = (String) data.get("password");
|
String password = (String) data.get("password");
|
||||||
String name = (String) data.get("name");
|
String name = (String) data.get("name");
|
||||||
@@ -89,4 +133,41 @@ public class SystemAuthController {
|
|||||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage()));
|
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Strips password hash from response objects */
|
||||||
|
private SystemUser sanitize(SystemUser u) {
|
||||||
|
u.setPassword("[PROTECTED]");
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SystemUser> sanitize(List<SystemUser> users) {
|
||||||
|
users.forEach(this::sanitize);
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asserts the calling JWT has one of the required roles, throws 403 otherwise */
|
||||||
|
private void requireRole(String... roles) {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
|
||||||
|
}
|
||||||
|
if (auth.getDetails() instanceof Claims claims) {
|
||||||
|
String role = (String) claims.get("role");
|
||||||
|
for (String r : roles) {
|
||||||
|
if (r.equals(role)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new org.springframework.security.access.AccessDeniedException(
|
||||||
|
"Insufficient role. Required: " + String.join(" or ", roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp(HttpServletRequest request) {
|
||||||
|
String xfHeader = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xfHeader != null && !xfHeader.isEmpty()) {
|
||||||
|
return xfHeader.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import java.util.List;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/notifications")
|
@RequestMapping("/api/notifications")
|
||||||
@CrossOrigin(origins = "*")
|
|
||||||
public class SystemNotificationController {
|
public class SystemNotificationController {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
package com.rit.canteen.sales.controller;
|
package com.rit.canteen.sales.controller;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.config.JwtUtil;
|
||||||
|
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||||
import com.rit.canteen.sales.model.*;
|
import com.rit.canteen.sales.model.*;
|
||||||
import com.rit.canteen.sales.service.UserService;
|
import com.rit.canteen.sales.service.UserService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@@ -18,94 +26,95 @@ public class UserController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
/**
|
@Autowired
|
||||||
* Check if a user exists by mobile number.
|
private JwtUtil jwtUtil;
|
||||||
* POST /api/auth/check
|
|
||||||
*/
|
@Autowired
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
// ── PUBLIC ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@PostMapping("/check")
|
@PostMapping("/check")
|
||||||
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
|
||||||
LoginResponse response = userService.checkUserExists(request.getMobileNumber());
|
LoginResponse response = userService.checkUserExists(request.getMobileNumber());
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a new user with mobile number, name and PIN.
|
|
||||||
* POST /api/auth/register
|
|
||||||
*/
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request) {
|
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request,
|
||||||
LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin());
|
HttpServletRequest httpRequest) {
|
||||||
if (response.isSuccess()) {
|
String ip = getClientIp(httpRequest);
|
||||||
return ResponseEntity.ok(response);
|
if (!rateLimiter.tryConsume(ip)) {
|
||||||
} else {
|
return ResponseEntity.status(429).build();
|
||||||
return ResponseEntity.badRequest().body(response);
|
|
||||||
}
|
}
|
||||||
|
LoginResponse response = userService.registerUser(
|
||||||
|
request.getMobileNumber(), request.getName(), request.getPin());
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
// Attach JWT on successful registration
|
||||||
|
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||||
|
if (userId != null) {
|
||||||
|
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||||
|
response.setToken(token);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify PIN and login an existing user.
|
|
||||||
* POST /api/auth/login
|
|
||||||
*/
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request) {
|
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
String ip = getClientIp(httpRequest);
|
||||||
|
if (!rateLimiter.tryConsume(ip)) {
|
||||||
|
LoginResponse rateResp = new LoginResponse(false, "Too many login attempts. Please wait 5 minutes.");
|
||||||
|
return ResponseEntity.status(429).body(rateResp);
|
||||||
|
}
|
||||||
LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin());
|
LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin());
|
||||||
if (response.isSuccess()) {
|
if (response.isSuccess()) {
|
||||||
|
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||||
|
if (userId != null) {
|
||||||
|
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||||
|
response.setToken(token);
|
||||||
|
}
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
} else {
|
|
||||||
return ResponseEntity.badRequest().body(response);
|
|
||||||
}
|
}
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout a user.
|
|
||||||
* POST /api/auth/logout
|
|
||||||
*/
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
||||||
LoginResponse response = userService.logout(request.getMobileNumber());
|
LoginResponse response = userService.logout(request.getMobileNumber());
|
||||||
if (response.isSuccess()) {
|
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
} else {
|
|
||||||
return ResponseEntity.badRequest().body(response);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── AUTHENTICATED (customer token required) ────────────────────────────────
|
||||||
* Change user PIN.
|
|
||||||
* POST /api/auth/change-pin
|
|
||||||
*/
|
|
||||||
@PostMapping("/change-pin")
|
@PostMapping("/change-pin")
|
||||||
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
|
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
|
||||||
LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
// Extra ownership check: the JWT's mobileNumber must match the request
|
||||||
if (response.isSuccess()) {
|
String tokenMobile = getAuthenticatedMobile();
|
||||||
return ResponseEntity.ok(response);
|
if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) {
|
||||||
} else {
|
return ResponseEntity.status(403).body(
|
||||||
return ResponseEntity.badRequest().body(response);
|
new LoginResponse(false, "You can only change your own PIN."));
|
||||||
}
|
}
|
||||||
|
LoginResponse response = userService.changePin(
|
||||||
|
request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
||||||
|
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user details by mobile number.
|
|
||||||
* GET /api/auth/user/{mobileNumber}
|
|
||||||
*/
|
|
||||||
@GetMapping("/user/{mobileNumber}")
|
@GetMapping("/user/{mobileNumber}")
|
||||||
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
|
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
|
||||||
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
// Customers can only fetch their own profile; staff can fetch any
|
||||||
if (userDto != null) {
|
String tokenMobile = getAuthenticatedMobile();
|
||||||
return ResponseEntity.ok(userDto);
|
if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) {
|
||||||
} else {
|
return ResponseEntity.status(403).build();
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
}
|
||||||
|
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
||||||
|
return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── STAFF/MASTER ONLY ────────────────────────────────────────────────────────
|
||||||
* Get all registered users for administration dashboard.
|
|
||||||
* GET /api/auth/users
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Get all registered users for administration dashboard.
|
|
||||||
* GET /api/auth/users
|
|
||||||
*/
|
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
|
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
|
||||||
@RequestParam(required = false) String search,
|
@RequestParam(required = false) String search,
|
||||||
@@ -115,41 +124,53 @@ public class UserController {
|
|||||||
return ResponseEntity.ok(users);
|
return ResponseEntity.ok(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user details as an administrator.
|
|
||||||
* PUT /api/auth/users/{id}
|
|
||||||
*/
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
public ResponseEntity<LoginResponse.UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) {
|
public ResponseEntity<LoginResponse.UserDto> updateUser(
|
||||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin());
|
@PathVariable Long id,
|
||||||
if (updated != null) {
|
@Valid @RequestBody UserUpdateRequest request) {
|
||||||
return ResponseEntity.ok(updated);
|
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
|
||||||
} else {
|
request.getMobileNumber(), request.getPin());
|
||||||
return ResponseEntity.notFound().build();
|
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a user from the system.
|
|
||||||
* DELETE /api/auth/users/{id}
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||||
userService.deleteUser(id);
|
userService.deleteUser(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle user suspension status.
|
|
||||||
* PATCH /api/auth/users/{id}/suspend
|
|
||||||
*/
|
|
||||||
@PatchMapping("/users/{id}/suspend")
|
@PatchMapping("/users/{id}/suspend")
|
||||||
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
|
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
|
||||||
LoginResponse.UserDto updated = userService.toggleSuspension(id);
|
LoginResponse.UserDto updated = userService.toggleSuspension(id);
|
||||||
if (updated != null) {
|
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||||
return ResponseEntity.ok(updated);
|
}
|
||||||
} else {
|
|
||||||
return ResponseEntity.notFound().build();
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private String getAuthenticatedMobile() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||||
|
// Customer tokens have the mobile as subject
|
||||||
|
String type = (String) claims.get("type");
|
||||||
|
if ("customer".equals(type)) {
|
||||||
|
return claims.getSubject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null; // Staff or system token — not a customer
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStaff() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null) return false;
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().startsWith("ROLE_MASTER")
|
||||||
|
|| a.getAuthority().startsWith("ROLE_MANAGER")
|
||||||
|
|| a.getAuthority().startsWith("ROLE_STAFF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp(HttpServletRequest request) {
|
||||||
|
String xfHeader = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim();
|
||||||
|
return request.getRemoteAddr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction;
|
|||||||
import com.rit.canteen.sales.model.User;
|
import com.rit.canteen.sales.model.User;
|
||||||
import com.rit.canteen.sales.service.TokenService;
|
import com.rit.canteen.sales.service.TokenService;
|
||||||
import com.rit.canteen.sales.service.UserService;
|
import com.rit.canteen.sales.service.UserService;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -18,17 +21,24 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/wallet")
|
@RequestMapping("/api/wallet")
|
||||||
public class WalletController {
|
public class WalletController {
|
||||||
|
|
||||||
|
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private com.rit.canteen.sales.repository.UserRepository userRepository;
|
private com.rit.canteen.sales.repository.UserRepository userRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||||
|
|
||||||
|
// ── CUSTOMER: own balance only ─────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/balance/{userId}")
|
@GetMapping("/balance/{userId}")
|
||||||
public ResponseEntity<?> getBalance(@PathVariable Long userId) {
|
public ResponseEntity<?> getBalance(@PathVariable Long userId) {
|
||||||
|
// Customers can only read their own balance
|
||||||
|
if (!canAccessUser(userId)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
User user = userRepository.findById(userId).orElse(null);
|
User user = userRepository.findById(userId).orElse(null);
|
||||||
|
|
||||||
if (user == null) return ResponseEntity.notFound().build();
|
if (user == null) return ResponseEntity.notFound().build();
|
||||||
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
|
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -36,6 +46,16 @@ public class WalletController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/transactions/{userId}")
|
||||||
|
public ResponseEntity<?> getTransactions(@PathVariable Long userId) {
|
||||||
|
if (!canAccessUser(userId)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STAFF/MASTER: wallet management ────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public ResponseEntity<List<Map<String, Object>>> getUsers() {
|
public ResponseEntity<List<Map<String, Object>>> getUsers() {
|
||||||
List<User> users = userRepository.findAll();
|
List<User> users = userRepository.findAll();
|
||||||
@@ -50,18 +70,22 @@ public class WalletController {
|
|||||||
return ResponseEntity.ok(userList);
|
return ResponseEntity.ok(userList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/transactions/{userId}")
|
|
||||||
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
|
|
||||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/topup")
|
@PostMapping("/topup")
|
||||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||||
try {
|
try {
|
||||||
Long userId = Long.valueOf(request.get("userId").toString());
|
Long userId = Long.valueOf(request.get("userId").toString());
|
||||||
BigDecimal amount = new BigDecimal(request.get("amount").toString());
|
BigDecimal amount = new BigDecimal(request.get("amount").toString());
|
||||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
|
||||||
|
|
||||||
|
// ── FIX: validate amount BEFORE touching the database ──
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Amount must be positive"));
|
||||||
|
}
|
||||||
|
if (amount.compareTo(MAX_TOPUP) > 0) {
|
||||||
|
return ResponseEntity.badRequest().body(
|
||||||
|
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||||
User updatedUser = tokenService.topUp(userId, amount, ref);
|
User updatedUser = tokenService.topUp(userId, amount, ref);
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
@@ -89,4 +113,27 @@ public class WalletController {
|
|||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
|
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the caller is a staff/manager/master, OR is the specific customer user.
|
||||||
|
*/
|
||||||
|
private boolean canAccessUser(Long targetUserId) {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) return false;
|
||||||
|
|
||||||
|
if (auth.getDetails() instanceof Claims claims) {
|
||||||
|
String role = (String) claims.get("role");
|
||||||
|
// Staff roles can access anyone
|
||||||
|
if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true;
|
||||||
|
// Customers can only access themselves
|
||||||
|
Object uid = claims.get("userId");
|
||||||
|
if (uid != null) {
|
||||||
|
Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||||
|
return tokenUserId.equals(targetUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public class LoginResponse {
|
|||||||
private String message;
|
private String message;
|
||||||
private boolean userExists;
|
private boolean userExists;
|
||||||
private UserDto user;
|
private UserDto user;
|
||||||
|
private String token; // JWT for customer session
|
||||||
|
|
||||||
public LoginResponse() {}
|
public LoginResponse() {}
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ public class LoginResponse {
|
|||||||
public UserDto getUser() { return user; }
|
public UserDto getUser() { return user; }
|
||||||
public void setUser(UserDto user) { this.user = user; }
|
public void setUser(UserDto user) { this.user = user; }
|
||||||
|
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public void setToken(String token) { this.token = token; }
|
||||||
|
|
||||||
public static class UserDto {
|
public static class UserDto {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String mobileNumber;
|
private String mobileNumber;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class SystemUserService {
|
|||||||
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
|
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
|
||||||
master.setViewOnly(false);
|
master.setViewOnly(false);
|
||||||
repository.save(master);
|
repository.save(master);
|
||||||
System.out.println(">>> SEEDED DEFAULT MASTER USER (Failsafe Source): " + masterUsername + " / " + masterPassword);
|
System.out.println(">>> SEEDED DEFAULT MASTER USER from failsafe source.");
|
||||||
} else {
|
} else {
|
||||||
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
|
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,15 @@ public class TokenService {
|
|||||||
User user = userRepository.findById(userId)
|
User user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
|
// ── FIX: Validate BEFORE touching the database ──
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new RuntimeException("Amount must be positive");
|
||||||
|
}
|
||||||
|
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
||||||
|
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int tokenCount = amount.intValue();
|
||||||
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
|
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
|
||||||
|
|
||||||
// High-Performance Batch Insertion using JDBC
|
// High-Performance Batch Insertion using JDBC
|
||||||
@@ -85,11 +93,6 @@ public class TokenService {
|
|||||||
// Update cached balance
|
// Update cached balance
|
||||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||||
BigDecimal newBalance = currentBalance.add(amount);
|
BigDecimal newBalance = currentBalance.add(amount);
|
||||||
|
|
||||||
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
|
||||||
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
|
||||||
}
|
|
||||||
|
|
||||||
user.setRitzTokenBalance(newBalance);
|
user.setRitzTokenBalance(newBalance);
|
||||||
User savedUser = userRepository.save(user);
|
User savedUser = userRepository.save(user);
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ public class TokenService {
|
|||||||
return savedUser;
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void spend(Long userId, BigDecimal amount, String orderRef) {
|
public void spend(Long userId, BigDecimal amount, String orderRef) {
|
||||||
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
|
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
spring.application.name=backend
|
spring.application.name=backend
|
||||||
server.address=0.0.0.0
|
server.address=0.0.0.0
|
||||||
|
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy
|
# ============================================================
|
||||||
spring.datasource.username=postgres
|
# DATABASE — use environment variables in production
|
||||||
spring.datasource.password=sidharth
|
# Set DB_URL, DB_USER, DB_PASSWORD as env vars before running
|
||||||
|
# ============================================================
|
||||||
|
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy}
|
||||||
|
spring.datasource.username=${DB_USER:postgres}
|
||||||
|
spring.datasource.password=${DB_PASSWORD:sidharth}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
spring.jpa.show-sql=true
|
spring.jpa.show-sql=false
|
||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||||
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
|
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
|
||||||
|
|
||||||
# Master Account Credentials
|
# ============================================================
|
||||||
app.master.username=admin
|
# JWT — CHANGE THIS SECRET IN PRODUCTION (min 256-bit key)
|
||||||
app.master.password=admin
|
# Set JWT_SECRET as an environment variable before running
|
||||||
|
# ============================================================
|
||||||
|
app.jwt.secret=${JWT_SECRET:3RiTCaNtEeNsUpErSeCrEtKeY2026!!xYzAbCdEfGhIjKlMn}
|
||||||
|
app.jwt.expiration-ms=86400000
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Master Account — use environment variables in production
|
||||||
|
# ============================================================
|
||||||
|
app.master.username=${MASTER_USER:admin}
|
||||||
|
app.master.password=${MASTER_PASSWORD:admin}
|
||||||
|
|
||||||
# File upload configuration
|
# File upload configuration
|
||||||
spring.servlet.multipart.max-file-size=10MB
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
spring.servlet.multipart.max-request-size=10MB
|
spring.servlet.multipart.max-request-size=10MB
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CORS — comma-separated list of allowed frontend origins
|
||||||
|
# Set APP_CORS_ORIGINS as env var in production
|
||||||
|
# ============================================================
|
||||||
|
app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000}
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
# Positeasy Clone Frontend (Counter/Admin)
|
||||||
|
|
||||||
|
## 🔐 Security Hardening & API Migration (Branch: krishna)
|
||||||
|
|
||||||
|
This branch contains critical security updates and infrastructure changes to support JWT-based authentication across the Positeasy ecosystem.
|
||||||
|
|
||||||
|
### Key Changes:
|
||||||
|
- **Authenticated API Wrapper**: Migrated from standard `fetch` to a centralized `src/api.ts` wrapper. This wrapper automatically handles:
|
||||||
|
- Injection of the `Authorization: Bearer <token>` header.
|
||||||
|
- Consistent error handling for API requests.
|
||||||
|
- **Security Hardening**: Updated all major screens (Login, POS, Orders, etc.) to use the new authenticated API layer.
|
||||||
|
- **Backend Integration**: Configured to work seamlessly with the new JWT-protected backend endpoints.
|
||||||
|
|
||||||
|
### Developer Instructions:
|
||||||
|
1. **Always use the API wrapper**: Import `api` from `@/api` (or `src/api.ts`) instead of using `fetch` directly.
|
||||||
|
2. **Migration Script**: `migrate_fetch.ps1` is included for reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# React + TypeScript + Vite
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|||||||
17
frontend/migrate_fetch.ps1
Normal file
17
frontend/migrate_fetch.ps1
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
$compDir = "src\components"
|
||||||
|
$files = Get-ChildItem -Path $compDir -Filter "*.tsx"
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$path = $file.FullName
|
||||||
|
$content = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
if ($content.Contains("await fetch(")) {
|
||||||
|
if (-not $content.Contains("from '../api'")) {
|
||||||
|
$content = "import { apiFetch } from '../api';" + [System.Environment]::NewLine + $content
|
||||||
|
}
|
||||||
|
$content = $content.Replace("await fetch(", "await apiFetch(")
|
||||||
|
[System.IO.File]::WriteAllText($path, $content, [System.Text.Encoding]::UTF8)
|
||||||
|
Write-Host "Updated component: $($file.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "Components migration complete."
|
||||||
51
frontend/src/api.ts
Normal file
51
frontend/src/api.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Authenticated fetch wrapper for the admin frontend.
|
||||||
|
* Automatically attaches the JWT from localStorage to every request.
|
||||||
|
*/
|
||||||
|
export function getAuthToken(): string | null {
|
||||||
|
const user = localStorage.getItem('systemUser');
|
||||||
|
if (!user) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(user);
|
||||||
|
return parsed.token || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated fetch. Redirects to /login on 401/403.
|
||||||
|
*/
|
||||||
|
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// Token expired or invalid — redirect to login
|
||||||
|
localStorage.removeItem('systemUser');
|
||||||
|
sessionStorage.clear();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Monitor, MapPin, Lock } from 'lucide-react';
|
import { X, Monitor, MapPin, Lock } from 'lucide-react';
|
||||||
@@ -19,7 +20,7 @@ const AddTerminalModal: React.FC<AddTerminalModalProps> = ({ isOpen, onClose, on
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/terminals', {
|
const response = await apiFetch('/api/terminals', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, location, pin }),
|
body: JSON.stringify({ name, location, pin }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
@@ -65,7 +66,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const fetchNotifications = async () => {
|
const fetchNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/notifications`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/notifications`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
@@ -115,7 +116,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const handleNotificationClick = async (notif: any) => {
|
const handleNotificationClick = async (notif: any) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' });
|
await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' });
|
||||||
if (notif.link) navigate(notif.link);
|
if (notif.link) navigate(notif.link);
|
||||||
setShowNotifications(false);
|
setShowNotifications(false);
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
@@ -126,7 +127,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' });
|
await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' });
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
setShowNotifications(false);
|
setShowNotifications(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
@@ -30,7 +31,7 @@ const PinVerificationModal: React.FC<PinVerificationModalProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/terminals/${terminalId}/verify-pin`, {
|
const response = await apiFetch(`/api/terminals/${terminalId}/verify-pin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pin: pinToVerify }),
|
body: JSON.stringify({ pin: pinToVerify }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -77,7 +78,7 @@ const ArchivedOrders: React.FC = () => {
|
|||||||
params.append('page', currentPage.toString());
|
params.append('page', currentPage.toString());
|
||||||
params.append('size', pageSize.toString());
|
params.append('size', pageSize.toString());
|
||||||
|
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data && data.content && Array.isArray(data.content)) {
|
if (data && data.content && Array.isArray(data.content)) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react';
|
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
@@ -78,7 +79,7 @@ const BaseMenu = () => {
|
|||||||
params.append('search', debouncedSearchTerm);
|
params.append('search', debouncedSearchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`http://${host}:8080/api/base-items?${params.toString()}`);
|
const response = await apiFetch(`http://${host}:8080/api/base-items?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && data.content) {
|
if (data && data.content) {
|
||||||
setItems(data.content);
|
setItems(data.content);
|
||||||
@@ -99,7 +100,7 @@ const BaseMenu = () => {
|
|||||||
setShowProductsModal(true);
|
setShowProductsModal(true);
|
||||||
setProductsLoading(true);
|
setProductsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`);
|
const response = await apiFetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAssociatedProducts(data);
|
setAssociatedProducts(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -117,7 +118,7 @@ const BaseMenu = () => {
|
|||||||
const method = editingItem ? 'PUT' : 'POST';
|
const method = editingItem ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newItem),
|
body: JSON.stringify(newItem),
|
||||||
@@ -142,7 +143,7 @@ const BaseMenu = () => {
|
|||||||
|
|
||||||
const handleToggleActive = async (item: BaseItem) => {
|
const handleToggleActive = async (item: BaseItem) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:8080/api/base-items/${item.id}`, {
|
const response = await apiFetch(`http://localhost:8080/api/base-items/${item.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...item, active: !item.active }),
|
body: JSON.stringify({ ...item, active: !item.active }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react';
|
import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -31,7 +32,7 @@ const Bills: React.FC = () => {
|
|||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/purchases/orders');
|
const response = await apiFetch('/api/purchases/orders');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setOrders(data);
|
setOrders(data);
|
||||||
@@ -50,7 +51,7 @@ const Bills: React.FC = () => {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount);
|
const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount);
|
||||||
|
|
||||||
const response = await fetch('/api/purchases/orders', {
|
const response = await apiFetch('/api/purchases/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, User, Phone, Tag, ChevronRight, UserCheck, UserMinus, MoreVertical, LayoutGrid, List, Edit2, Trash2, Shield, X, Eye, EyeOff, Loader2, AlertCircle, CheckCircle, CircleDollarSign } from 'lucide-react';
|
import { Search, User, Phone, Tag, ChevronRight, UserCheck, UserMinus, MoreVertical, LayoutGrid, List, Edit2, Trash2, Shield, X, Eye, EyeOff, Loader2, AlertCircle, CheckCircle, CircleDollarSign } from 'lucide-react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
@@ -63,7 +64,7 @@ const Customers: React.FC = () => {
|
|||||||
params.append('search', debouncedSearchTerm);
|
params.append('search', debouncedSearchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`http://${host}:8080/api/auth/users?${params.toString()}`);
|
const response = await apiFetch(`http://${host}:8080/api/auth/users?${params.toString()}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && data.content) {
|
if (data && data.content) {
|
||||||
@@ -106,7 +107,7 @@ const Customers: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}`, {
|
const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -125,7 +126,7 @@ const Customers: React.FC = () => {
|
|||||||
const handleSuspendToggle = async (user: UserDto) => {
|
const handleSuspendToggle = async (user: UserDto) => {
|
||||||
try {
|
try {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, {
|
const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -165,7 +166,7 @@ const Customers: React.FC = () => {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, {
|
const response = await apiFetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(editForm),
|
body: JSON.stringify(editForm),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +106,7 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
|
console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
|
||||||
const response = await fetch(url);
|
const response = await apiFetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setData(result);
|
setData(result);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
@@ -81,7 +82,7 @@ const Feedback: React.FC = () => {
|
|||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/stats`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback/stats`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -93,7 +94,7 @@ const Feedback: React.FC = () => {
|
|||||||
|
|
||||||
const fetchFeedbacks = async () => {
|
const fetchFeedbacks = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setFeedbacks(data?.content || []);
|
setFeedbacks(data?.content || []);
|
||||||
setTotalElements(data?.totalElements || 0);
|
setTotalElements(data?.totalElements || 0);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -68,7 +69,7 @@ const IntentDashboard: React.FC<IntentDashboardProps> = ({ title }) => {
|
|||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
@@ -49,7 +50,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
|||||||
|
|
||||||
const fetchVendors = async () => {
|
const fetchVendors = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/vendors`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/vendors`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setVendors(result);
|
setVendors(result);
|
||||||
@@ -62,7 +63,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -104,7 +105,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
|||||||
date: new Date(newOrder.date).toISOString()
|
date: new Date(newOrder.date).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`, {
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ const Login = () => {
|
|||||||
sessionStorage.setItem('userRole', user.role.toLowerCase());
|
sessionStorage.setItem('userRole', user.role.toLowerCase());
|
||||||
sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || []));
|
sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || []));
|
||||||
|
|
||||||
// Persist user profile for personalized greetings and settings
|
// Persist user profile + JWT token for authenticated API calls
|
||||||
localStorage.setItem('systemUser', JSON.stringify(user));
|
localStorage.setItem('systemUser', JSON.stringify(user)); // user object now includes `token`
|
||||||
|
|
||||||
navigate('/store-dashboard');
|
navigate('/store-dashboard');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.text();
|
const error = await response.json().catch(() => ({ error: 'Invalid credentials' }));
|
||||||
alert(error || 'Invalid credentials');
|
alert(error.error || 'Invalid credentials');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Ticket,
|
Ticket,
|
||||||
@@ -44,7 +45,7 @@ const ManageCoupons = () => {
|
|||||||
const fetchCoupons = async () => {
|
const fetchCoupons = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/coupons');
|
const response = await apiFetch('/api/coupons');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setCoupons(data);
|
setCoupons(data);
|
||||||
@@ -63,7 +64,7 @@ const ManageCoupons = () => {
|
|||||||
const handleCreateCoupon = async (e: React.FormEvent) => {
|
const handleCreateCoupon = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/coupons', {
|
const response = await apiFetch('/api/coupons', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -85,7 +86,7 @@ const ManageCoupons = () => {
|
|||||||
|
|
||||||
const handleToggleStatus = async (id: number) => {
|
const handleToggleStatus = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' });
|
const response = await apiFetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' });
|
||||||
if (response.ok) fetchCoupons();
|
if (response.ok) fetchCoupons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling status:', error);
|
console.error('Error toggling status:', error);
|
||||||
@@ -95,7 +96,7 @@ const ManageCoupons = () => {
|
|||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!window.confirm('Are you sure you want to delete this coupon?')) return;
|
if (!window.confirm('Are you sure you want to delete this coupon?')) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/coupons/${id}`, { method: 'DELETE' });
|
const response = await apiFetch(`/api/coupons/${id}`, { method: 'DELETE' });
|
||||||
if (response.ok) fetchCoupons();
|
if (response.ok) fetchCoupons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting coupon:', error);
|
console.error('Error deleting coupon:', error);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
@@ -33,7 +34,7 @@ const ManageWallets = () => {
|
|||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/wallet/users');
|
const response = await apiFetch('/api/wallet/users');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUsers(data);
|
setUsers(data);
|
||||||
@@ -57,7 +58,7 @@ const ManageWallets = () => {
|
|||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/wallet/topup', {
|
const response = await apiFetch('/api/wallet/topup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +67,7 @@ const Managers = () => {
|
|||||||
const fetchManagers = async () => {
|
const fetchManagers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/system/managers');
|
const res = await apiFetch('/api/system/managers');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Backend uses 'permissions' field, map it to 'sections' for the component if needed
|
// Backend uses 'permissions' field, map it to 'sections' for the component if needed
|
||||||
@@ -99,7 +100,7 @@ const Managers = () => {
|
|||||||
const handleCreateAccount = async (e: React.FormEvent) => {
|
const handleCreateAccount = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/managers', {
|
const response = await apiFetch('/api/system/managers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -127,7 +128,7 @@ const Managers = () => {
|
|||||||
const dismissManager = async (id: string) => {
|
const dismissManager = async (id: string) => {
|
||||||
if (window.confirm('Are you sure you want to dismiss this manager?')) {
|
if (window.confirm('Are you sure you want to dismiss this manager?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/system/managers/${id}`, {
|
const response = await apiFetch(`/api/system/managers/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
X, Search, RefreshCw, Edit2, Package, Image as ImageIcon,
|
X, Search, RefreshCw, Edit2, Package, Image as ImageIcon,
|
||||||
@@ -75,7 +76,7 @@ const NewArrivals: React.FC = () => {
|
|||||||
const fetchDrafts = async () => {
|
const fetchDrafts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/products/drafts');
|
const response = await apiFetch('/api/products/drafts');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setDrafts(data);
|
setDrafts(data);
|
||||||
@@ -116,7 +117,7 @@ const NewArrivals: React.FC = () => {
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/products/${formData.id}/publish`, {
|
const response = await apiFetch(`/api/products/${formData.id}/publish`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(formData),
|
body: JSON.stringify(formData),
|
||||||
@@ -137,7 +138,7 @@ const NewArrivals: React.FC = () => {
|
|||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!window.confirm('Delete this draft product?')) return;
|
if (!window.confirm('Delete this draft product?')) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/products/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/products/${id}`, { method: 'DELETE' });
|
||||||
fetchDrafts();
|
fetchDrafts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting draft:', error);
|
console.error('Error deleting draft:', error);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -103,7 +104,7 @@ const Orders: React.FC = () => {
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/products`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/products`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAllProducts(data);
|
setAllProducts(data);
|
||||||
@@ -127,7 +128,7 @@ const Orders: React.FC = () => {
|
|||||||
params.append('page', currentPage.toString());
|
params.append('page', currentPage.toString());
|
||||||
params.append('size', pageSize.toString());
|
params.append('size', pageSize.toString());
|
||||||
|
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data && data.content && Array.isArray(data.content)) {
|
if (data && data.content && Array.isArray(data.content)) {
|
||||||
@@ -156,7 +157,7 @@ const Orders: React.FC = () => {
|
|||||||
|
|
||||||
const handleApproveOrder = async (orderId: number) => {
|
const handleApproveOrder = async (orderId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: 'COMPLETED' })
|
body: JSON.stringify({ status: 'COMPLETED' })
|
||||||
@@ -174,7 +175,7 @@ const Orders: React.FC = () => {
|
|||||||
|
|
||||||
const handleMarkUndelivered = async (orderId: number) => {
|
const handleMarkUndelivered = async (orderId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: 'PAID' })
|
body: JSON.stringify({ status: 'PAID' })
|
||||||
@@ -230,7 +231,7 @@ const Orders: React.FC = () => {
|
|||||||
if (!selectedOrder) return;
|
if (!selectedOrder) return;
|
||||||
setIsUpdatingOrder(true);
|
setIsUpdatingOrder(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -297,7 +298,7 @@ const Orders: React.FC = () => {
|
|||||||
setIsRegenerating(true);
|
setIsRegenerating(true);
|
||||||
try {
|
try {
|
||||||
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;
|
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, Tag, Package, Image as ImageIcon, Barcode, DollarSign, ChevronDown, Clock, Check, Trash2, Database } from 'lucide-react';
|
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, Tag, Package, Image as ImageIcon, Barcode, DollarSign, ChevronDown, Clock, Check, Trash2, Database } from 'lucide-react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
@@ -161,7 +162,7 @@ const Products = () => {
|
|||||||
params.append('search', debouncedSearchTerm);
|
params.append('search', debouncedSearchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`http://${host}:8080/api/products?${params.toString()}`);
|
const response = await apiFetch(`http://${host}:8080/api/products?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && data.content) {
|
if (data && data.content) {
|
||||||
setProducts(data.content);
|
setProducts(data.content);
|
||||||
@@ -179,7 +180,7 @@ const Products = () => {
|
|||||||
|
|
||||||
const fetchBaseItems = async () => {
|
const fetchBaseItems = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:8080/api/base-items?size=100');
|
const response = await apiFetch('http://localhost:8080/api/base-items?size=100');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setBaseItems(data.content || data);
|
setBaseItems(data.content || data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -189,7 +190,7 @@ const Products = () => {
|
|||||||
|
|
||||||
const fetchAllStalls = async () => {
|
const fetchAllStalls = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:8080/api/stalls');
|
const response = await apiFetch('http://localhost:8080/api/stalls');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAllStalls(data);
|
setAllStalls(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -205,7 +206,7 @@ const Products = () => {
|
|||||||
const method = editingProduct ? 'PUT' : 'POST';
|
const method = editingProduct ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(formData),
|
body: JSON.stringify(formData),
|
||||||
@@ -233,7 +234,7 @@ const Products = () => {
|
|||||||
|
|
||||||
const handleToggleActive = async (product: Product) => {
|
const handleToggleActive = async (product: Product) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}`, {
|
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...product, active: !product.active }),
|
body: JSON.stringify({ ...product, active: !product.active }),
|
||||||
@@ -249,7 +250,7 @@ const Products = () => {
|
|||||||
|
|
||||||
const handleToggleStock = async (product: Product) => {
|
const handleToggleStock = async (product: Product) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, {
|
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -264,7 +265,7 @@ const Products = () => {
|
|||||||
const handleDelete = async (product: Product) => {
|
const handleDelete = async (product: Product) => {
|
||||||
if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return;
|
if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}`, {
|
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -71,7 +72,7 @@ const PurchaseAnalytics = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/purchases/orders');
|
const response = await apiFetch('/api/purchases/orders');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
processAnalytics(data);
|
processAnalytics(data);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, ShoppingCart, Eye, Plus, X, Loader2, CheckCircle, Trash2, Edit2 } from 'lucide-react';
|
import { Search, ShoppingCart, Eye, Plus, X, Loader2, CheckCircle, Trash2, Edit2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ const Purchases: React.FC = () => {
|
|||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/purchases/orders');
|
const response = await apiFetch('/api/purchases/orders');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setOrders(data);
|
setOrders(data);
|
||||||
@@ -79,7 +80,7 @@ const Purchases: React.FC = () => {
|
|||||||
|
|
||||||
const fetchVendors = async () => {
|
const fetchVendors = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/purchases/vendors');
|
const response = await apiFetch('/api/purchases/vendors');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setVendors(data);
|
setVendors(data);
|
||||||
@@ -91,7 +92,7 @@ const Purchases: React.FC = () => {
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/products?size=1000');
|
const response = await apiFetch('/api/products?size=1000');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAvailableProducts(data.content || data);
|
setAvailableProducts(data.content || data);
|
||||||
@@ -150,7 +151,7 @@ const Purchases: React.FC = () => {
|
|||||||
date: new Date(newOrder.date).toISOString()
|
date: new Date(newOrder.date).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('/api/purchases/orders', {
|
const response = await apiFetch('/api/purchases/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -172,7 +173,7 @@ const Purchases: React.FC = () => {
|
|||||||
const deleteOrder = async (id: number) => {
|
const deleteOrder = async (id: number) => {
|
||||||
if (!window.confirm('Delete this purchase order?')) return;
|
if (!window.confirm('Delete this purchase order?')) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/purchases/orders/${id}`, { method: 'DELETE' });
|
await apiFetch(`/api/purchases/orders/${id}`, { method: 'DELETE' });
|
||||||
fetchOrders();
|
fetchOrders();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting order:', error);
|
console.error('Error deleting order:', error);
|
||||||
@@ -197,7 +198,7 @@ const Purchases: React.FC = () => {
|
|||||||
const order = orders.find(o => o.id === orderId);
|
const order = orders.find(o => o.id === orderId);
|
||||||
if (!order) return;
|
if (!order) return;
|
||||||
|
|
||||||
const response = await fetch(`/api/purchases/orders`, {
|
const response = await apiFetch(`/api/purchases/orders`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...order, status: newStatus })
|
body: JSON.stringify({ ...order, status: newStatus })
|
||||||
@@ -229,7 +230,7 @@ const Purchases: React.FC = () => {
|
|||||||
const fetchOrderHistory = async (order: PurchaseOrder) => {
|
const fetchOrderHistory = async (order: PurchaseOrder) => {
|
||||||
try {
|
try {
|
||||||
setActiveHistoryOrder(order);
|
setActiveHistoryOrder(order);
|
||||||
const response = await fetch(`/api/purchases/orders/${order.id}/history`);
|
const response = await apiFetch(`/api/purchases/orders/${order.id}/history`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setOrderHistory(data);
|
setOrderHistory(data);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
@@ -66,7 +67,7 @@ const Reports: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const fromStr = dateRange.from.toISOString();
|
const fromStr = dateRange.from.toISOString();
|
||||||
const toStr = dateRange.to.toISOString();
|
const toStr = dateRange.to.toISOString();
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`);
|
const response = await apiFetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -56,11 +57,11 @@ const RitzPage: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
|
|
||||||
const statsRes = await fetch(`http://${host}:8080/api/wallet/stats`);
|
const statsRes = await apiFetch(`http://${host}:8080/api/wallet/stats`);
|
||||||
const statsData = await statsRes.json();
|
const statsData = await statsRes.json();
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
|
|
||||||
const transRes = await fetch(`http://${host}:8080/api/wallet/transactions/all`);
|
const transRes = await apiFetch(`http://${host}:8080/api/wallet/transactions/all`);
|
||||||
const transData = await transRes.json();
|
const transData = await transRes.json();
|
||||||
setTransactions(Array.isArray(transData) ? transData : []);
|
setTransactions(Array.isArray(transData) ? transData : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@@ -52,7 +53,7 @@ const RitzCirculation: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const res = await fetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
|
const res = await apiFetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
|
||||||
const data: PageResponse = await res.json();
|
const data: PageResponse = await res.json();
|
||||||
|
|
||||||
setTokens(data.content || []);
|
setTokens(data.content || []);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
@@ -47,7 +48,7 @@ const Settings = () => {
|
|||||||
|
|
||||||
const fetchAdmins = async () => {
|
const fetchAdmins = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/admins');
|
const response = await apiFetch('/api/system/admins');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAdmins(data);
|
setAdmins(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -69,7 +70,7 @@ const Settings = () => {
|
|||||||
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/update-master', {
|
const response = await apiFetch('/api/system/update-master', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -102,7 +103,7 @@ const Settings = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/admins', {
|
const response = await apiFetch('/api/system/admins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newAdmin)
|
body: JSON.stringify(newAdmin)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +51,7 @@ const Staff = () => {
|
|||||||
const fetchStaff = async () => {
|
const fetchStaff = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/system/staff');
|
const res = await apiFetch('/api/system/staff');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStaffList(data);
|
setStaffList(data);
|
||||||
@@ -69,7 +70,7 @@ const Staff = () => {
|
|||||||
const handleCreateStaff = async (e: React.FormEvent) => {
|
const handleCreateStaff = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/system/staff', {
|
const response = await apiFetch('/api/system/staff', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -97,7 +98,7 @@ const Staff = () => {
|
|||||||
const deleteStaff = async (id: string) => {
|
const deleteStaff = async (id: string) => {
|
||||||
if (window.confirm('Are you sure you want to remove this staff member?')) {
|
if (window.confirm('Are you sure you want to remove this staff member?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/system/staff/${id}`, {
|
const response = await apiFetch(`/api/system/staff/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -94,7 +95,7 @@ const Stalls: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/stalls`);
|
const response = await apiFetch(`http://${host}:8080/api/stalls`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStalls(data);
|
setStalls(data);
|
||||||
@@ -126,7 +127,7 @@ const Stalls: React.FC = () => {
|
|||||||
? `http://${host}:8080/api/stalls/${editingStall.id}`
|
? `http://${host}:8080/api/stalls/${editingStall.id}`
|
||||||
: `http://${host}:8080/api/stalls`;
|
: `http://${host}:8080/api/stalls`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
method: editingStall ? 'PUT' : 'POST',
|
method: editingStall ? 'PUT' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -173,7 +174,7 @@ const Stalls: React.FC = () => {
|
|||||||
if (!window.confirm('Are you sure you want to delete this stall?')) return;
|
if (!window.confirm('Are you sure you want to delete this stall?')) return;
|
||||||
try {
|
try {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/stalls/${id}`, {
|
const response = await apiFetch(`http://${host}:8080/api/stalls/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -216,7 +217,7 @@ const Stalls: React.FC = () => {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const response = await fetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, {
|
const response = await apiFetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -99,7 +100,7 @@ const StoreDashboard = () => {
|
|||||||
params.append('from', range.from);
|
params.append('from', range.from);
|
||||||
params.append('to', range.to);
|
params.append('to', range.to);
|
||||||
|
|
||||||
const response = await fetch(`/api/dashboard/stats?${params.toString()}`);
|
const response = await apiFetch(`/api/dashboard/stats?${params.toString()}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Dashboard data received successfully:', data);
|
console.log('Dashboard data received successfully:', data);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ const Terminals = () => {
|
|||||||
const fetchTerminals = async () => {
|
const fetchTerminals = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/terminals');
|
const response = await apiFetch('/api/terminals');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTerminals(data);
|
setTerminals(data);
|
||||||
@@ -52,7 +53,7 @@ const Terminals = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (window.confirm('Are you sure you want to remove this terminal?')) {
|
if (window.confirm('Are you sure you want to remove this terminal?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/terminals/${id}`, { method: 'DELETE' });
|
const response = await apiFetch(`/api/terminals/${id}`, { method: 'DELETE' });
|
||||||
if (response.ok) fetchTerminals();
|
if (response.ok) fetchTerminals();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete terminal:', error);
|
console.error('Failed to delete terminal:', error);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +54,7 @@ const VendorDashboard = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/dashboard/procurement');
|
const response = await apiFetch('/api/dashboard/procurement');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setData(result);
|
setData(result);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiFetch } from '../api';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, Building2, Phone, Mail, MoreVertical, List, LayoutGrid, Edit2, Trash2, X, Loader2, AlertCircle, CheckCircle, Plus } from 'lucide-react';
|
import { Search, Building2, Phone, Mail, MoreVertical, List, LayoutGrid, Edit2, Trash2, X, Loader2, AlertCircle, CheckCircle, Plus } from 'lucide-react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
@@ -43,7 +44,7 @@ const Vendors: React.FC = () => {
|
|||||||
const fetchVendors = async () => {
|
const fetchVendors = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/purchases/vendors');
|
const response = await apiFetch('/api/purchases/vendors');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setVendors(data);
|
setVendors(data);
|
||||||
@@ -85,7 +86,7 @@ const Vendors: React.FC = () => {
|
|||||||
if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return;
|
if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/purchases/vendors/${vendor.id}`, {
|
const response = await apiFetch(`/api/purchases/vendors/${vendor.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -111,7 +112,7 @@ const Vendors: React.FC = () => {
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/purchases/vendors', {
|
const response = await apiFetch('/api/purchases/vendors', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form),
|
body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form),
|
||||||
|
|||||||
Reference in New Issue
Block a user