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()
// ── 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()

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.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<TerminalDTO> getAllTerminals() {
return terminalService.getAllTerminals().stream()
@@ -75,10 +82,15 @@ public class TerminalController {
// Order Lookup (ESP32 uses X-API-KEY)
// ──────────────────────────────────────────────────────────────
@GetMapping("/orders/{orderNumber}")
public ResponseEntity<?> getOrderForTerminal(
@PathVariable String orderNumber,
@RequestHeader("X-API-KEY") String apiKey) {
@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> terminal = terminalRepository.findByApiKey(apiKey);
@@ -86,8 +98,115 @@ public class TerminalController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Invalid API Key"));
}
// 2. Fetch Order
Optional<Order> orderOpt = orderRepository.findByOrderNumber(orderNumber);
List<Map<String, String>> 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<String> 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<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();
@@ -104,9 +223,153 @@ public class TerminalController {
}
return ResponseEntity.ok(order);
} else {
}
}
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")
@@ -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<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")
public ResponseEntity<?> registerDeviceOtp(@RequestBody Map<String, String> body) {
public ResponseEntity<?> registerDeviceOtpPost(@RequestBody Map<String, String> 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<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);
}
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;
@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

View File

@@ -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

View File

@@ -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

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).
@@ -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.