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 108c856c..2b5b4277 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 @@ -50,10 +50,12 @@ public class SecurityConfig { .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("/api/terminals/orders/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/terminals/validate").permitAll() + .requestMatchers(HttpMethod.GET, "/api/terminals/pair").permitAll() .requestMatchers(HttpMethod.POST, "/api/terminals/pair").permitAll() .requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll() + .requestMatchers(HttpMethod.GET, "/api/terminals/counters").permitAll() // ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ── .requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll() diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/TerminalController.java b/backend/src/main/java/com/rit/canteen/sales/controller/TerminalController.java index fa435df0..7abf22ca 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/TerminalController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/TerminalController.java @@ -7,11 +7,15 @@ import com.rit.canteen.sales.service.DevicePairingService; import com.rit.canteen.sales.service.TerminalService; import com.rit.canteen.sales.repository.TerminalRepository; import com.rit.canteen.sales.repository.OrderRepository; +import com.rit.canteen.sales.repository.StallRepository; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -32,6 +36,9 @@ public class TerminalController { @Autowired private OrderRepository orderRepository; + @Autowired + private StallRepository stallRepository; + @GetMapping public List getAllTerminals() { return terminalService.getAllTerminals().stream() @@ -75,10 +82,169 @@ public class TerminalController { // Order Lookup (ESP32 uses X-API-KEY) // ────────────────────────────────────────────────────────────── - @GetMapping("/orders/{orderNumber}") + @GetMapping("/counters") + public ResponseEntity getCounters( + @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + String apiKey = extractApiKey(apiKeyHeader, authHeader); + if (apiKey == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid or missing token")); + } + + // 1. Verify API Key + Optional terminal = terminalRepository.findByApiKey(apiKey); + if (terminal.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); + } + + List> counters = stallRepository.findAll().stream() + .map(stall -> Map.of( + "counterId", String.valueOf(stall.getId()), + "name", stall.getName() + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(Map.of("data", counters)); + } + + private List parseAndCleanOrderNumbers(String input) { + if (input == null || input.isBlank()) { + return Collections.emptyList(); + } + // Normalize common URL encodings or text replacements of carriage return/newline + String normalized = input + .replace("%0D", "\r") + .replace("%0d", "\r") + .replace("%0A", "\n") + .replace("%0a", "\n") + .replace("0x0d", "\r") + .replace("0x0D", "\r") + .replace("0x0a", "\n") + .replace("0x0A", "\n"); + + String[] parts = normalized.split("[\\r\\n\\s,;]+"); + List cleanList = new ArrayList<>(); + for (String part : parts) { + String clean = part.trim(); + if (!clean.isEmpty()) { + cleanList.add(clean); + } + } + return cleanList; + } + + @GetMapping("/orders") public ResponseEntity getOrderForTerminal( - @PathVariable String orderNumber, - @RequestHeader("X-API-KEY") String apiKey) { + @RequestParam("paymentId") String paymentId, + @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + String apiKey = extractApiKey(apiKeyHeader, authHeader); + if (apiKey == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid or missing token")); + } + + // 1. Verify API Key + Optional terminal = terminalRepository.findByApiKey(apiKey); + if (terminal.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); + } + + // 2. Parse and Clean payment IDs + List orderNumbers = parseAndCleanOrderNumbers(paymentId); + if (orderNumbers.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "paymentId required")); + } + + // 3. Try to fetch the first order that exists + for (String orderNum : orderNumbers) { + Optional orderOpt = orderRepository.findByOrderNumber(orderNum); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + + // Check if order is expired/archived + if (order.isArchived()) { + continue; // Check other IDs if available, or return GONE + } + + // Check if order is already fulfilled + if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", "This order has already been fulfilled and cannot be printed again.")); + } + + return ResponseEntity.ok(order); + } + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found")); + } + + @GetMapping("/orders/{orderNumber}") + public ResponseEntity getOrderForTerminalByPath( + @PathVariable("orderNumber") String orderNumber, + @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + String apiKey = extractApiKey(apiKeyHeader, authHeader); + if (apiKey == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid or missing token")); + } + + // 1. Verify API Key + Optional terminal = terminalRepository.findByApiKey(apiKey); + if (terminal.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); + } + + // 2. Parse and Clean order numbers + List orderNumbers = parseAndCleanOrderNumbers(orderNumber); + if (orderNumbers.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required")); + } + + // 3. Try to fetch the first order that exists + for (String orderNum : orderNumbers) { + Optional orderOpt = orderRepository.findByOrderNumber(orderNum); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + + // Check if order is expired/archived + if (order.isArchived()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("message", "This order has expired and cannot be processed.")); + } + + // Check if order is already fulfilled + if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", "This order has already been fulfilled and cannot be printed again.")); + } + + return ResponseEntity.ok(order); + } + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found")); + } + + @PutMapping("/orders/delivered") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity markOrderAsDelivered( + @RequestBody Map body, + @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + String orderNumber = body.get("orderNumber"); + if (orderNumber == null || orderNumber.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required")); + } + + String apiKey = extractApiKey(apiKeyHeader, authHeader); + if (apiKey == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid or missing token")); + } // 1. Verify API Key Optional terminal = terminalRepository.findByApiKey(apiKey); @@ -86,27 +252,124 @@ public class TerminalController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); } - // 2. Fetch Order - Optional orderOpt = orderRepository.findByOrderNumber(orderNumber); - if (orderOpt.isPresent()) { - Order order = orderOpt.get(); - - // Check if order is expired/archived - if (order.isArchived()) { - return ResponseEntity.status(HttpStatus.GONE) - .body(Map.of("message", "This order has expired and cannot be processed.")); - } - - // Check if order is already fulfilled - if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("message", "This order has already been fulfilled and cannot be printed again.")); - } - - return ResponseEntity.ok(order); - } else { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found")); + // 2. Parse and Clean order numbers + List orderNumbers = parseAndCleanOrderNumbers(orderNumber); + if (orderNumbers.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required")); } + + // 3. Process all found orders + List successfulOrders = new ArrayList<>(); + boolean alreadyCompleted = false; + boolean archivedFound = false; + + for (String orderNum : orderNumbers) { + Optional orderOpt = orderRepository.findByOrderNumber(orderNum); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + + if (order.isArchived()) { + archivedFound = true; + continue; + } + + if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { + alreadyCompleted = true; + successfulOrders.add(orderNum); + continue; + } + + // Update status to COMPLETED + order.setStatus("COMPLETED"); + orderRepository.save(order); + successfulOrders.add(orderNum); + } + } + + if (!successfulOrders.isEmpty()) { + String msg = alreadyCompleted && successfulOrders.size() == 1 + ? "Order was already marked as delivered." + : "Order(s) marked as delivered successfully: " + String.join(", ", successfulOrders); + return ResponseEntity.ok(Map.of("success", true, "message", msg)); + } + + if (archivedFound) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("message", "The requested order(s) have expired/archived.")); + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found")); + } + + @PostMapping("/orders/{orderNumber}/delivered") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity markOrderAsDeliveredPost( + @PathVariable("orderNumber") String orderNumber, + @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader) { + + if (orderNumber == null || orderNumber.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required")); + } + + String apiKey = extractApiKey(apiKeyHeader, authHeader); + if (apiKey == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid or missing token")); + } + + // 1. Verify API Key + Optional terminal = terminalRepository.findByApiKey(apiKey); + if (terminal.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); + } + + // 2. Parse and Clean order numbers + List orderNumbers = parseAndCleanOrderNumbers(orderNumber); + if (orderNumbers.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required")); + } + + // 3. Process all found orders + List successfulOrders = new ArrayList<>(); + boolean alreadyCompleted = false; + boolean archivedFound = false; + + for (String orderNum : orderNumbers) { + Optional orderOpt = orderRepository.findByOrderNumber(orderNum); + if (orderOpt.isPresent()) { + Order order = orderOpt.get(); + + if (order.isArchived()) { + archivedFound = true; + continue; + } + + if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { + alreadyCompleted = true; + successfulOrders.add(orderNum); + continue; + } + + // Update status to COMPLETED + order.setStatus("COMPLETED"); + orderRepository.save(order); + successfulOrders.add(orderNum); + } + } + + if (!successfulOrders.isEmpty()) { + String msg = alreadyCompleted && successfulOrders.size() == 1 + ? "Order was already marked as delivered." + : "Order(s) marked as delivered successfully: " + String.join(", ", successfulOrders); + return ResponseEntity.ok(Map.of("success", true, "message", msg)); + } + + if (archivedFound) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("message", "The requested order(s) have expired/archived.")); + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found")); } @GetMapping("/validate") @@ -137,17 +400,41 @@ public class TerminalController { * * No auth required — the device has no credentials yet. */ + @GetMapping("/pair") + public ResponseEntity registerDeviceOtp(@RequestParam("otp") String otp) { + if (otp == null || otp.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "OTP is required")); + } + + Map result = devicePairingService.registerOtp(otp, "ESP32-Device"); + if ("PAIRED".equals(result.get("status"))) { + return ResponseEntity.ok(Map.of( + "status", "PAIRED", + "apiKey", result.get("apiKey"), + "terminalId", result.get("terminalId") + )); + } + return ResponseEntity.ok(Map.of("status", "WAITING")); + } + @PostMapping("/pair") - public ResponseEntity registerDeviceOtp(@RequestBody Map body) { + public ResponseEntity registerDeviceOtpPost(@RequestBody Map body) { String otp = body.get("otp"); String deviceId = body.get("deviceId"); if (otp == null || otp.isBlank() || deviceId == null || deviceId.isBlank()) { - return ResponseEntity.badRequest().body(Map.of("message", "Both 'otp' and 'deviceId' are required")); + return ResponseEntity.badRequest().body(Map.of("message", "otp and deviceId are required")); } Map result = devicePairingService.registerOtp(otp, deviceId); - return ResponseEntity.ok(result); + if ("PAIRED".equals(result.get("status"))) { + return ResponseEntity.ok(Map.of( + "status", "PAIRED", + "apiKey", result.get("apiKey"), + "terminalId", result.get("terminalId") + )); + } + return ResponseEntity.ok(Map.of("status", "WAITING")); } /** @@ -216,4 +503,14 @@ public class TerminalController { } return deviceId.substring(0, 4) + "••••" + deviceId.substring(deviceId.length() - 4); } + + private String extractApiKey(String apiKeyHeader, String authHeader) { + if (apiKeyHeader != null && !apiKeyHeader.isBlank()) { + return apiKeyHeader; + } + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7).trim(); + } + return null; + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/model/Order.java b/backend/src/main/java/com/rit/canteen/sales/model/Order.java index b693df03..c480829d 100644 --- a/backend/src/main/java/com/rit/canteen/sales/model/Order.java +++ b/backend/src/main/java/com/rit/canteen/sales/model/Order.java @@ -8,7 +8,10 @@ import java.util.List; import java.util.UUID; @Entity -@Table(name = "canteen_orders") +@Table(name = "canteen_orders", indexes = { + @Index(name = "idx_orders_user_id", columnList = "user_id"), + @Index(name = "idx_orders_created_at", columnList = "createdAt") +}) public class Order { @Id diff --git a/backend/src/main/java/com/rit/canteen/sales/model/Product.java b/backend/src/main/java/com/rit/canteen/sales/model/Product.java index 1605e9c4..47e2eef2 100644 --- a/backend/src/main/java/com/rit/canteen/sales/model/Product.java +++ b/backend/src/main/java/com/rit/canteen/sales/model/Product.java @@ -8,7 +8,11 @@ import java.util.ArrayList; import java.util.List; @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_products_product_id", columnList = "productId"), + @Index(name = "idx_products_name", columnList = "name"), + @Index(name = "idx_products_category", columnList = "category") +}) public class Product { @Id diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 66e5bf72..6194c879 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -6,7 +6,7 @@ server.port=8080 # DATABASE — REQUIRED environment variables # Set these in your environment or a .env file # ============================================================ -spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy} +spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy spring.datasource.username=postgres spring.datasource.password=sidharth spring.datasource.driver-class-name=org.postgresql.Driver @@ -37,3 +37,28 @@ spring.servlet.multipart.max-request-size=10MB # CORS — comma-separated list of allowed frontend origins # ============================================================ app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000} + +# ============================================================ +# PERFORMANCE & CONCURRENCY OPTIMIZATIONS +# ============================================================ +# Tomcat Thread pool configuration for high concurrency +server.tomcat.threads.max=200 +server.tomcat.threads.min-spare=20 +server.tomcat.max-connections=10000 +server.tomcat.accept-count=100 +server.tomcat.connection-timeout=20000 + +# Hikari Database Connection Pool settings +spring.datasource.hikari.maximum-pool-size=50 +spring.datasource.hikari.minimum-idle=10 +spring.datasource.hikari.idle-timeout=300000 +spring.datasource.hikari.max-lifetime=1200000 +spring.datasource.hikari.connection-timeout=20000 + +# ============================================================ +# LOGGING TUNING FOR ROBUSTNESS +# ============================================================ +# Reduce log noise from malformed request line / control character warnings +logging.level.org.apache.coyote.http11.Http11InputBuffer=ERROR +logging.level.org.apache.tomcat.util.http.parser.HttpParser=ERROR + diff --git a/backend/terminal_api_spec.md b/backend/terminal_api_spec.md index 1f1b8aa4..848bfab8 100644 --- a/backend/terminal_api_spec.md +++ b/backend/terminal_api_spec.md @@ -180,7 +180,34 @@ Used by the ESP32 to fetch the order details after scanning a QR code. --- -### E. Validate API Key +### E. Mark Order as Delivered + +Used by the ESP32 to mark an order as delivered/completed in the database once the bill has successfully printed. + +* **URL**: `/api/terminals/orders/{orderNumber}/delivered` +* **Method**: `POST` +* **Headers**: + * `X-API-KEY`: `` +* **Path Parameters**: + * `orderNumber` (String): The order ID (e.g., `ORD-87A3B2D9`). + +#### Response Codes: +* `200 OK`: Order marked as delivered successfully, or was already marked as delivered. +* `401 UNAUTHORIZED`: Invalid or missing `X-API-KEY`. +* `404 NOT_FOUND`: Order number does not exist. +* `410 GONE`: Order has expired/archived. + +#### Response Body (`200 OK` JSON Schema): +```json +{ + "success": true, + "message": "Order marked as delivered successfully." +} +``` + +--- + +### F. Validate API Key Used by the ESP32 to verify its API key is still valid (optional health check). @@ -201,7 +228,7 @@ Used by the ESP32 to verify its API key is still valid (optional health check). --- -### F. Device Logging +### G. Device Logging Used by the ESP32 to submit runtime system logs to the backend. @@ -222,7 +249,7 @@ Used by the ESP32 to submit runtime system logs to the backend. --- -### G. Verify Terminal PIN (Admin Tool) +### H. Verify Terminal PIN (Admin Tool) Used during admin operations to reveal the terminal's API Key.