BillBot Code added

This commit is contained in:
Sidharth Prabhu
2026-06-22 09:25:39 +05:30
parent a96e36e409
commit a609e98836
6 changed files with 391 additions and 33 deletions

View File

@@ -50,10 +50,12 @@ public class SecurityConfig {
.requestMatchers("/api/stock/stream").permitAll() .requestMatchers("/api/stock/stream").permitAll()
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ── // ── 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/validate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/terminals/pair").permitAll()
.requestMatchers(HttpMethod.POST, "/api/terminals/pair").permitAll() .requestMatchers(HttpMethod.POST, "/api/terminals/pair").permitAll()
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").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) ── // ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ──
.requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll() .requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll()

View File

@@ -7,11 +7,15 @@ import com.rit.canteen.sales.service.DevicePairingService;
import com.rit.canteen.sales.service.TerminalService; import com.rit.canteen.sales.service.TerminalService;
import com.rit.canteen.sales.repository.TerminalRepository; import com.rit.canteen.sales.repository.TerminalRepository;
import com.rit.canteen.sales.repository.OrderRepository; 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.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -32,6 +36,9 @@ public class TerminalController {
@Autowired @Autowired
private OrderRepository orderRepository; private OrderRepository orderRepository;
@Autowired
private StallRepository stallRepository;
@GetMapping @GetMapping
public List<TerminalDTO> getAllTerminals() { public List<TerminalDTO> getAllTerminals() {
return terminalService.getAllTerminals().stream() return terminalService.getAllTerminals().stream()
@@ -75,10 +82,15 @@ public class TerminalController {
// Order Lookup (ESP32 uses X-API-KEY) // Order Lookup (ESP32 uses X-API-KEY)
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@GetMapping("/orders/{orderNumber}") @GetMapping("/counters")
public ResponseEntity<?> getOrderForTerminal( public ResponseEntity<?> getCounters(
@PathVariable String orderNumber, @RequestHeader(value = "X-API-KEY", required = false) String apiKeyHeader,
@RequestHeader("X-API-KEY") String apiKey) { @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 // 1. Verify API Key
Optional<Terminal> terminal = terminalRepository.findByApiKey(apiKey); Optional<Terminal> terminal = terminalRepository.findByApiKey(apiKey);
@@ -86,27 +98,278 @@ public class TerminalController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key")); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key"));
} }
// 2. Fetch Order List<Map<String, String>> counters = stallRepository.findAll().stream()
Optional<Order> orderOpt = orderRepository.findByOrderNumber(orderNumber); .map(stall -> Map.of(
if (orderOpt.isPresent()) { "counterId", String.valueOf(stall.getId()),
Order order = orderOpt.get(); "name", stall.getName()
))
.collect(Collectors.toList());
// Check if order is expired/archived return ResponseEntity.ok(Map.of("data", counters));
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 private List<String> parseAndCleanOrderNumbers(String input) {
if ("COMPLETED".equalsIgnoreCase(order.getStatus())) { if (input == null || input.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return Collections.emptyList();
.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"));
} }
// 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<String> cleanList = new ArrayList<>();
for (String part : parts) {
String clean = part.trim();
if (!clean.isEmpty()) {
cleanList.add(clean);
}
}
return cleanList;
}
@GetMapping("/orders")
public ResponseEntity<?> getOrderForTerminal(
@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> 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<String> 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<Order> 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> 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<String> 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<Order> 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<String, String> 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> 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<String> orderNumbers = parseAndCleanOrderNumbers(orderNumber);
if (orderNumbers.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required"));
}
// 3. Process all found orders
List<String> successfulOrders = new ArrayList<>();
boolean alreadyCompleted = false;
boolean archivedFound = false;
for (String orderNum : orderNumbers) {
Optional<Order> 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> 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<String> orderNumbers = parseAndCleanOrderNumbers(orderNumber);
if (orderNumbers.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "orderNumber required"));
}
// 3. Process all found orders
List<String> successfulOrders = new ArrayList<>();
boolean alreadyCompleted = false;
boolean archivedFound = false;
for (String orderNum : orderNumbers) {
Optional<Order> 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") @GetMapping("/validate")
@@ -137,17 +400,41 @@ public class TerminalController {
* *
* No auth required — the device has no credentials yet. * 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<String, Object> 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") @PostMapping("/pair")
public ResponseEntity<?> registerDeviceOtp(@RequestBody Map<String, String> body) { public ResponseEntity<?> registerDeviceOtpPost(@RequestBody Map<String, String> body) {
String otp = body.get("otp"); String otp = body.get("otp");
String deviceId = body.get("deviceId"); String deviceId = body.get("deviceId");
if (otp == null || otp.isBlank() || deviceId == null || deviceId.isBlank()) { 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<String, Object> result = devicePairingService.registerOtp(otp, deviceId); Map<String, Object> 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); 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;
}
} }

View File

@@ -8,7 +8,10 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
@Entity @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 { public class Order {
@Id @Id

View File

@@ -8,7 +8,11 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @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 { public class Product {
@Id @Id

View File

@@ -6,7 +6,7 @@ server.port=8080
# DATABASE — REQUIRED environment variables # DATABASE — REQUIRED environment variables
# Set these in your environment or a .env file # 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.username=postgres
spring.datasource.password=sidharth spring.datasource.password=sidharth
spring.datasource.driver-class-name=org.postgresql.Driver 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 # CORS — comma-separated list of allowed frontend origins
# ============================================================ # ============================================================
app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000} 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

View File

@@ -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`: `<YOUR_TERMINAL_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). 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. 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. Used during admin operations to reveal the terminal's API Key.