diff --git a/README.md b/README.md
new file mode 100644
index 00000000..b39057c8
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/backend/pom.xml b/backend/pom.xml
index a695789e..8303b3a4 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -63,6 +63,30 @@
spring-boot-starter-test
test
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
+
+ com.bucket4j
+ bucket4j-core
+ 8.10.1
+
diff --git a/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java b/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java
new file mode 100644
index 00000000..4ffaf2db
--- /dev/null
+++ b/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java
@@ -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 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);
+ }
+}
diff --git a/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java b/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java
new file mode 100644
index 00000000..3a18650f
--- /dev/null
+++ b/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java
@@ -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 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 getPermissionsFromToken(String token) {
+ return (List) validateToken(token).get("permissions");
+ }
+}
diff --git a/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java b/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java
new file mode 100644
index 00000000..0afee02f
--- /dev/null
+++ b/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java
@@ -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 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();
+ }
+}
diff --git a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java
index 07d4c602..df26e912 100644
--- a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java
+++ b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java
@@ -1,37 +1,81 @@
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.Configuration;
+import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.Customizer;
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.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
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
@EnableWebSecurity
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
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
- .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
- .requestMatchers("/api/auth/**").permitAll()
- .requestMatchers("/api/notifications/**").permitAll()
- .requestMatchers("/api/**").permitAll()
- .anyRequest().permitAll()
- );
+
+ // ββ PUBLIC: Auth endpoints (login / register for both user types) ββ
+ .requestMatchers(HttpMethod.POST, "/api/system/login").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();
}
@@ -43,11 +87,22 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
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 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.setAllowedHeaders(List.of("*"));
+ configuration.setExposedHeaders(List.of("Authorization"));
configuration.setAllowCredentials(false);
+ configuration.setMaxAge(3600L);
+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
diff --git a/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java b/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java
index 96a35ac3..188b0027 100644
--- a/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java
+++ b/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java
@@ -1,24 +1,13 @@
package com.rit.canteen.sales.config;
-import org.springframework.context.annotation.Bean;
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
public class WebConfig {
-
- @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);
- }
- };
- }
+ // CORS handled by SecurityConfig β do not add CorsRegistry here
}
diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java
index 6e7667b5..79cb6313 100644
--- a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java
+++ b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java
@@ -23,8 +23,12 @@ public class CouponController {
@Autowired
private com.rit.canteen.sales.service.TokenService tokenService;
+ // ββ PUBLIC (authenticated): any logged-in user can redeem ββββββββββββββ
+
@GetMapping
public List getAllCoupons() {
+ // Return only active, non-expired coupons for customers
+ // (STAFF/MASTER see all β handled client-side via role check)
return couponRepository.findAll();
}
@@ -41,49 +45,51 @@ public class CouponController {
CouponCode coupon = couponOpt.get();
- // 1. Basic Validations
if (!coupon.getIsActive()) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
}
-
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
}
- // 2. Global Usage Limit
- if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
- return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
- }
-
- // 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
+ // ββ FIX: Atomic check-and-insert to prevent race-condition double redemption ββ
+ // Use INSERT with conflict handling β if the DB unique constraint fires, it means
+ // a concurrent request already redeemed. We catch the exception and return an error.
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);
couponRepository.save(coupon);
// Credit tokens
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(
- "success", true,
+ "success", true,
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
"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) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
}
}
+ // ββ STAFF/MASTER ONLY: coupon management ββββββββββββββββββββββββββββββ
+
@PostMapping
public ResponseEntity> createCoupon(@RequestBody CouponCode coupon) {
+ // SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
return ResponseEntity.badRequest().body("Coupon code already exists");
}
diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java
index 39a85f4a..ba82e716 100644
--- a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java
+++ b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java
@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.Order;
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.repository.OrderRepository;
import com.rit.canteen.sales.repository.ProductRepository;
+import io.jsonwebtoken.Claims;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
import com.rit.canteen.sales.service.TokenService;
import org.springframework.format.annotation.DateTimeFormat;
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 java.math.BigDecimal;
@@ -34,7 +38,7 @@ public class OrderController {
@Autowired
private ProductRepository productRepository;
-
+
@Autowired
private UserRepository userRepository;
@@ -44,9 +48,10 @@ public class OrderController {
@Autowired
private TokenService tokenService;
- // Use ThreadLocal to safely store conflicts for the current request context
private static final ThreadLocal>> requestConflicts = new ThreadLocal<>();
+ // ββ STAFF/MASTER: all orders ββββββββββββββββββββββββββββββββββββββββββ
+
@GetMapping("/all")
public ResponseEntity> getAllOrders(
@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 = "10") int size) {
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"));
Specification spec = (root, query, cb) -> {
List predicates = new ArrayList<>();
-
- // Add archive filter
predicates.add(cb.equal(root.get("isArchived"), archived));
-
- if (startDate != null) {
- predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
- }
- if (endDate != null) {
- 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 (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
+ if (endDate != null) 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()) {
String searchLower = "%" + search.toLowerCase() + "%";
Join userJoin = root.join("user", JoinType.LEFT);
-
- Predicate searchPredicate = cb.or(
+ predicates.add(cb.or(
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
cb.like(cb.lower(root.get("orderNumber")), searchLower),
cb.like(cb.lower(userJoin.get("name")), 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())) {
root.fetch("user", JoinType.LEFT);
}
-
return cb.and(predicates.toArray(new Predicate[0]));
};
-
Page orderPage = orderRepository.findAll(spec, pageable);
return ResponseEntity.ok(orderPage);
} catch (Exception e) {
- System.err.println("Error fetching orders with Specification: " + e.getMessage());
- e.printStackTrace();
- Map error = new HashMap<>();
- error.put("error", e.getMessage());
- return ResponseEntity.status(500).body(error);
+ return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
@@ -125,31 +105,65 @@ public class OrderController {
}
}
+ // ββ CUSTOMER: place order βββββββββββββββββββββββββββββββββββββββββββββ
+
@PostMapping
@org.springframework.transaction.annotation.Transactional
public ResponseEntity> placeOrder(@RequestBody Order order) {
- System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() +
- " | Total: " + order.getTotalAmount() +
- " | Items: " + (order.getItems() != null ? order.getItems().size() : 0));
+ // ββ SECURITY: Verify userId matches the JWT, or is placed by staff ββ
+ Long tokenUserId = getTokenUserId();
+ 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()) {
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 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