Bill Bot 1
This commit is contained in:
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
backend/src/.DS_Store
vendored
Normal file
BIN
backend/src/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
backend/src/main/.DS_Store
vendored
Normal file
BIN
backend/src/main/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -51,8 +51,13 @@ public class SecurityConfig {
|
||||
|
||||
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/terminals/validate").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/terminals/pair").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll()
|
||||
|
||||
// ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ──
|
||||
.requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll()
|
||||
|
||||
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
||||
|
||||
@@ -66,10 +71,14 @@ public class SecurityConfig {
|
||||
.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/wallet/topup").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()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/change-pin").authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/api/auth/users/*").authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
|
||||
|
||||
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
||||
.requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF", "OPERATOR")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.model.DeviceLog;
|
||||
import com.rit.canteen.sales.service.DeviceLogService;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/device-logs")
|
||||
public class DeviceLogController {
|
||||
|
||||
@Autowired
|
||||
private DeviceLogService deviceLogService;
|
||||
|
||||
/**
|
||||
* POST /api/device-logs
|
||||
* Accepts JSON: { "device_id": "...", "message": "..." }
|
||||
* Called by ESP32 Bill-Bot devices to log events.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Void> createLog(@RequestBody Map<String, String> body) {
|
||||
String deviceId = body.get("device_id");
|
||||
String message = body.get("message");
|
||||
|
||||
if (deviceId == null || message == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
deviceLogService.saveLog(deviceId, message);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/device-logs
|
||||
* Returns all device logs (newest first) for admin dashboard.
|
||||
*/
|
||||
@GetMapping
|
||||
public List<DeviceLog> getAllLogs() {
|
||||
return deviceLogService.getAllLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/device-logs/{deviceId}
|
||||
* Returns logs for a specific device.
|
||||
*/
|
||||
@GetMapping("/{deviceId}")
|
||||
public List<DeviceLog> getLogsByDevice(@PathVariable String deviceId) {
|
||||
return deviceLogService.getLogsByDeviceId(deviceId);
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,11 @@ public class OrderController {
|
||||
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
|
||||
try {
|
||||
return orderRepository.findById(id).map(existingOrder -> {
|
||||
// ── SECURITY: Verify ownership of order ──
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(existingOrder.getUserId()) && !isStaff()) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.rit.canteen.sales.controller;
|
||||
import com.rit.canteen.sales.model.Order;
|
||||
import com.rit.canteen.sales.model.Terminal;
|
||||
import com.rit.canteen.sales.model.TerminalDTO;
|
||||
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;
|
||||
@@ -22,6 +23,9 @@ public class TerminalController {
|
||||
@Autowired
|
||||
private TerminalService terminalService;
|
||||
|
||||
@Autowired
|
||||
private DevicePairingService devicePairingService;
|
||||
|
||||
@Autowired
|
||||
private TerminalRepository terminalRepository;
|
||||
|
||||
@@ -36,7 +40,10 @@ public class TerminalController {
|
||||
t.getName(),
|
||||
t.getLocation(),
|
||||
"********",
|
||||
"****"
|
||||
"****",
|
||||
t.isPaired(),
|
||||
t.getDeviceId() != null ? maskDeviceId(t.getDeviceId()) : null,
|
||||
t.getPairedAt()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@@ -64,6 +71,10 @@ public class TerminalController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Order Lookup (ESP32 uses X-API-KEY)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/orders/{orderNumber}")
|
||||
public ResponseEntity<?> getOrderForTerminal(
|
||||
@PathVariable String orderNumber,
|
||||
@@ -97,4 +108,112 @@ public class TerminalController {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "Order not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/validate")
|
||||
public ResponseEntity<?> validateApiKey(@RequestHeader("X-API-KEY") String apiKey) {
|
||||
Optional<Terminal> terminal = terminalRepository.findByApiKey(apiKey);
|
||||
if (terminal.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("status", "INVALID", "message", "Invalid API Key"));
|
||||
}
|
||||
Terminal t = terminal.get();
|
||||
System.out.println(">>> Terminal Connected & Validated: " + t.getName() + " [ID: " + t.getId() + ", Location: " + t.getLocation() + "]");
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "VALID",
|
||||
"terminalId", t.getId(),
|
||||
"name", t.getName(),
|
||||
"location", t.getLocation()
|
||||
));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// OTP-Based Device Pairing
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/terminals/pair
|
||||
* Called by ESP32 during setup. The device sends its OTP and chipId.
|
||||
* If pairing is already completed (admin linked it), returns the apiKey.
|
||||
* Otherwise returns status "WAITING".
|
||||
*
|
||||
* No auth required — the device has no credentials yet.
|
||||
*/
|
||||
@PostMapping("/pair")
|
||||
public ResponseEntity<?> registerDeviceOtp(@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"));
|
||||
}
|
||||
|
||||
Map<String, Object> result = devicePairingService.registerOtp(otp, deviceId);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/terminals/{id}/link-device
|
||||
* Called by admin dashboard. Admin enters the OTP shown on the ESP32 screen.
|
||||
* Links the device to this terminal record.
|
||||
*
|
||||
* Requires JWT auth (admin role).
|
||||
*/
|
||||
@PostMapping("/{id}/link-device")
|
||||
public ResponseEntity<?> linkDevice(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
String otp = body.get("otp");
|
||||
|
||||
if (otp == null || otp.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "OTP is required"));
|
||||
}
|
||||
|
||||
Optional<Terminal> result = devicePairingService.linkDevice(id, otp);
|
||||
if (result.isPresent()) {
|
||||
Terminal t = result.get();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Device linked successfully",
|
||||
"terminalId", t.getId(),
|
||||
"name", t.getName(),
|
||||
"deviceId", t.getDeviceId()
|
||||
));
|
||||
} else {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("message", "Invalid or expired OTP. Please check the code on the device screen and try again."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/terminals/{id}/unpair
|
||||
* Called by admin dashboard to disconnect a device from a terminal.
|
||||
* The device will need to re-pair on next boot.
|
||||
*/
|
||||
@PostMapping("/{id}/unpair")
|
||||
public ResponseEntity<?> unpairDevice(@PathVariable Long id) {
|
||||
Optional<Terminal> result = devicePairingService.unpairDevice(id);
|
||||
if (result.isPresent()) {
|
||||
return ResponseEntity.ok(Map.of("message", "Device unpaired successfully"));
|
||||
} else {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("message", "Terminal not found"));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Masks a device ID for display: "Esp 32 - AABBCCDDEEFF" → "Esp 32 - AA••••••EEFF"
|
||||
*/
|
||||
private String maskDeviceId(String deviceId) {
|
||||
if (deviceId == null || deviceId.length() < 8) return deviceId;
|
||||
// Find the last part (MAC portion) of the device ID
|
||||
int dashIndex = deviceId.lastIndexOf('-');
|
||||
if (dashIndex >= 0 && dashIndex + 5 < deviceId.length()) {
|
||||
String prefix = deviceId.substring(0, dashIndex + 1).trim();
|
||||
String mac = deviceId.substring(dashIndex + 1).trim();
|
||||
if (mac.length() >= 6) {
|
||||
return prefix + " " + mac.substring(0, 2) + "••••" + mac.substring(mac.length() - 4);
|
||||
}
|
||||
}
|
||||
return deviceId.substring(0, 4) + "••••" + deviceId.substring(deviceId.length() - 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,11 @@ public class UserController {
|
||||
public ResponseEntity<LoginResponse.UserDto> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserUpdateRequest request) {
|
||||
// Extra ownership check: customers can only update their own profile; staff can update any
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(id) && !isStaff()) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
|
||||
request.getMobileNumber(), request.getPin());
|
||||
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||
@@ -147,6 +152,17 @@ public class UserController {
|
||||
|
||||
// ── 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 String getAuthenticatedMobile() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||
|
||||
@@ -74,6 +74,9 @@ public class WalletController {
|
||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
Long userId = Long.valueOf(request.get("userId").toString());
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
BigDecimal amount = new BigDecimal(request.get("amount").toString());
|
||||
|
||||
// ── FIX: validate amount BEFORE touching the database ──
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "device_logs")
|
||||
public class DeviceLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "device_id", nullable = false)
|
||||
private String deviceId;
|
||||
|
||||
@Column(nullable = false, length = 2000)
|
||||
private String message;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public DeviceLog() {}
|
||||
|
||||
public DeviceLog(String deviceId, String message) {
|
||||
this.deviceId = deviceId;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getDeviceId() { return deviceId; }
|
||||
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "terminals")
|
||||
@@ -23,6 +24,15 @@ public class Terminal {
|
||||
@Column(unique = true)
|
||||
private String apiKey;
|
||||
|
||||
@Column(name = "device_id")
|
||||
private String deviceId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "boolean default false")
|
||||
private boolean paired = false;
|
||||
|
||||
@Column(name = "paired_at")
|
||||
private LocalDateTime pairedAt;
|
||||
|
||||
public Terminal() {}
|
||||
|
||||
public Terminal(String name, String location, String pin, String apiKey) {
|
||||
@@ -47,4 +57,13 @@ public class Terminal {
|
||||
|
||||
public String getApiKey() { return apiKey; }
|
||||
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
|
||||
|
||||
public String getDeviceId() { return deviceId; }
|
||||
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||
|
||||
public boolean isPaired() { return paired; }
|
||||
public void setPaired(boolean paired) { this.paired = paired; }
|
||||
|
||||
public LocalDateTime getPairedAt() { return pairedAt; }
|
||||
public void setPairedAt(LocalDateTime pairedAt) { this.pairedAt = pairedAt; }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class TerminalDTO {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String location;
|
||||
private String apiKey;
|
||||
private String pin;
|
||||
private boolean paired;
|
||||
private String deviceId;
|
||||
private LocalDateTime pairedAt;
|
||||
|
||||
public TerminalDTO() {}
|
||||
|
||||
public TerminalDTO(Long id, String name, String location, String apiKey, String pin) {
|
||||
public TerminalDTO(Long id, String name, String location, String apiKey, String pin,
|
||||
boolean paired, String deviceId, LocalDateTime pairedAt) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.location = location;
|
||||
this.apiKey = apiKey;
|
||||
this.pin = pin;
|
||||
this.paired = paired;
|
||||
this.deviceId = deviceId;
|
||||
this.pairedAt = pairedAt;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
@@ -32,4 +41,13 @@ public class TerminalDTO {
|
||||
|
||||
public String getPin() { return pin; }
|
||||
public void setPin(String pin) { this.pin = pin; }
|
||||
|
||||
public boolean isPaired() { return paired; }
|
||||
public void setPaired(boolean paired) { this.paired = paired; }
|
||||
|
||||
public String getDeviceId() { return deviceId; }
|
||||
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||
|
||||
public LocalDateTime getPairedAt() { return pairedAt; }
|
||||
public void setPairedAt(LocalDateTime pairedAt) { this.pairedAt = pairedAt; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.rit.canteen.sales.repository;
|
||||
|
||||
import com.rit.canteen.sales.model.DeviceLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface DeviceLogRepository extends JpaRepository<DeviceLog, Long> {
|
||||
List<DeviceLog> findAllByOrderByCreatedAtDesc();
|
||||
List<DeviceLog> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.rit.canteen.sales.service;
|
||||
|
||||
import com.rit.canteen.sales.model.DeviceLog;
|
||||
import com.rit.canteen.sales.repository.DeviceLogRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class DeviceLogService {
|
||||
|
||||
@Autowired
|
||||
private DeviceLogRepository deviceLogRepository;
|
||||
|
||||
@Transactional
|
||||
public DeviceLog saveLog(String deviceId, String message) {
|
||||
DeviceLog log = new DeviceLog(deviceId, message);
|
||||
return deviceLogRepository.save(log);
|
||||
}
|
||||
|
||||
public List<DeviceLog> getAllLogs() {
|
||||
return deviceLogRepository.findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public List<DeviceLog> getLogsByDeviceId(String deviceId) {
|
||||
return deviceLogRepository.findByDeviceIdOrderByCreatedAtDesc(deviceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.rit.canteen.sales.service;
|
||||
|
||||
import com.rit.canteen.sales.model.Terminal;
|
||||
import com.rit.canteen.sales.repository.TerminalRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Manages the in-memory OTP pairing lifecycle for ESP32 terminal devices.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Device boots, generates a 6-digit OTP, displays it on screen
|
||||
* 2. Device calls registerOtp() — stores OTP + deviceId in memory (5-min TTL)
|
||||
* 3. Admin sees OTP on device screen, enters it in dashboard against a terminal
|
||||
* 4. Admin calls linkDevice() — matches OTP, binds device to terminal, returns apiKey
|
||||
* 5. Device polls checkPairingStatus() — gets back apiKey once admin completes step 4
|
||||
*/
|
||||
@Service
|
||||
public class DevicePairingService {
|
||||
|
||||
private static final long OTP_TTL_MS = 5 * 60 * 1000L; // 5 minutes
|
||||
|
||||
@Autowired
|
||||
private TerminalRepository terminalRepository;
|
||||
|
||||
/**
|
||||
* Represents a pending pairing request from a device.
|
||||
*/
|
||||
private static class PairingRequest {
|
||||
final String otp;
|
||||
final String deviceId;
|
||||
final Instant createdAt;
|
||||
// Set once admin links the device
|
||||
volatile String apiKey;
|
||||
volatile Long terminalId;
|
||||
volatile boolean completed;
|
||||
|
||||
PairingRequest(String otp, String deviceId) {
|
||||
this.otp = otp;
|
||||
this.deviceId = deviceId;
|
||||
this.createdAt = Instant.now();
|
||||
this.completed = false;
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return Instant.now().toEpochMilli() - createdAt.toEpochMilli() > OTP_TTL_MS;
|
||||
}
|
||||
}
|
||||
|
||||
// OTP → PairingRequest
|
||||
private final ConcurrentHashMap<String, PairingRequest> pendingPairings = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Called by the ESP32 device. Registers (or refreshes) the device's OTP in memory.
|
||||
* If the OTP was already matched by an admin, returns the assigned apiKey.
|
||||
*
|
||||
* @return Map with status ("WAITING" or "PAIRED") and optionally "apiKey"
|
||||
*/
|
||||
public Map<String, Object> registerOtp(String otp, String deviceId) {
|
||||
PairingRequest existing = pendingPairings.get(otp);
|
||||
|
||||
// If this OTP was already linked by admin, return the apiKey
|
||||
if (existing != null && existing.completed && existing.deviceId.equals(deviceId)) {
|
||||
// Clean up after delivering the key
|
||||
pendingPairings.remove(otp);
|
||||
return Map.of(
|
||||
"status", "PAIRED",
|
||||
"apiKey", existing.apiKey,
|
||||
"terminalId", existing.terminalId
|
||||
);
|
||||
}
|
||||
|
||||
// Register or refresh the OTP
|
||||
pendingPairings.put(otp, new PairingRequest(otp, deviceId));
|
||||
|
||||
return Map.of("status", "WAITING");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the admin dashboard. Links a device's OTP to a specific terminal.
|
||||
*
|
||||
* @return Optional.empty() if OTP not found/expired, or the updated Terminal
|
||||
*/
|
||||
public Optional<Terminal> linkDevice(Long terminalId, String otp) {
|
||||
PairingRequest request = pendingPairings.get(otp);
|
||||
|
||||
if (request == null || request.isExpired()) {
|
||||
pendingPairings.remove(otp); // Clean up expired
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<Terminal> terminalOpt = terminalRepository.findById(terminalId);
|
||||
if (terminalOpt.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Terminal terminal = terminalOpt.get();
|
||||
|
||||
// Bind the device to the terminal
|
||||
terminal.setDeviceId(request.deviceId);
|
||||
terminal.setPaired(true);
|
||||
terminal.setPairedAt(LocalDateTime.now());
|
||||
terminalRepository.save(terminal);
|
||||
|
||||
// Mark the pairing as completed so the device can pick up the apiKey
|
||||
request.apiKey = terminal.getApiKey();
|
||||
request.terminalId = terminal.getId();
|
||||
request.completed = true;
|
||||
|
||||
System.out.println(">>> Device paired: " + request.deviceId +
|
||||
" → Terminal: " + terminal.getName() + " [ID: " + terminal.getId() + "]");
|
||||
|
||||
return Optional.of(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpairs a device from a terminal, clearing its deviceId and paired status.
|
||||
*/
|
||||
public Optional<Terminal> unpairDevice(Long terminalId) {
|
||||
Optional<Terminal> terminalOpt = terminalRepository.findById(terminalId);
|
||||
if (terminalOpt.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Terminal terminal = terminalOpt.get();
|
||||
terminal.setDeviceId(null);
|
||||
terminal.setPaired(false);
|
||||
terminal.setPairedAt(null);
|
||||
terminalRepository.save(terminal);
|
||||
|
||||
System.out.println(">>> Device unpaired from Terminal: " + terminal.getName() +
|
||||
" [ID: " + terminal.getId() + "]");
|
||||
|
||||
return Optional.of(terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic cleanup of expired OTP entries (runs every 60 seconds).
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void cleanupExpiredOtps() {
|
||||
pendingPairings.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
spring.application.name=backend
|
||||
server.address=0.0.0.0
|
||||
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.username=${DB_USER:postgres}
|
||||
spring.datasource.password=${DB_PASSWORD:}
|
||||
spring.datasource.username=postgres
|
||||
spring.datasource.password=sidharth
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
327
backend/terminal_api_spec.md
Normal file
327
backend/terminal_api_spec.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Terminal Integration & ESP32 Firmware API Specification
|
||||
|
||||
This document provides the API specifications of the Spring Boot backend for integrating terminal hardware (ESP32 controllers that scan QR codes and print bills).
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
### For Paired Devices
|
||||
Once a device is paired, all terminal-specific APIs use **API Key authentication**.
|
||||
- **Header Key**: `X-API-KEY`
|
||||
- **Value**: `POS-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` (Auto-generated when a terminal is created).
|
||||
|
||||
### For Unpaired Devices (During Pairing)
|
||||
The pairing endpoint requires no authentication — the device has no credentials yet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Device Onboarding (OTP-Based Pairing)
|
||||
|
||||
### Flow Overview
|
||||
|
||||
1. **Admin** creates a terminal in the dashboard (name, location, PIN)
|
||||
2. **ESP32** boots with no stored API key → enters pairing mode
|
||||
3. **ESP32** generates a 6-digit OTP and displays it on screen
|
||||
4. **ESP32** POSTs the OTP + its chipId to `POST /api/terminals/pair`
|
||||
5. **Admin** enters the OTP in the dashboard against the specific terminal
|
||||
6. **Backend** matches the OTP, binds the device to the terminal
|
||||
7. **ESP32** polls `POST /api/terminals/pair` again → receives `"PAIRED"` + API key
|
||||
8. **ESP32** saves the API key to NVS flash and restarts
|
||||
9. All subsequent communication uses `X-API-KEY` header — no further pairing needed
|
||||
|
||||
### A. Register Device OTP (ESP32 → Backend)
|
||||
|
||||
Used by the ESP32 to register its OTP and poll for pairing completion.
|
||||
|
||||
* **URL**: `/api/terminals/pair`
|
||||
* **Method**: `POST`
|
||||
* **Auth**: None (public endpoint)
|
||||
* **Headers**:
|
||||
* `Content-Type`: `application/json`
|
||||
* **Request Body**:
|
||||
```json
|
||||
{
|
||||
"otp": "482916",
|
||||
"deviceId": "Esp 32 - AABBCCDDEEFF"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response — Waiting (Admin hasn't linked yet):
|
||||
```json
|
||||
{
|
||||
"status": "WAITING"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response — Paired (Admin completed linking):
|
||||
```json
|
||||
{
|
||||
"status": "PAIRED",
|
||||
"apiKey": "POS-EF9832B743CA90B2381F0A1B2C3D4E5F",
|
||||
"terminalId": 1
|
||||
}
|
||||
```
|
||||
|
||||
* **Response Codes**:
|
||||
* `200 OK`: Always — check `status` field for state.
|
||||
* `400 BAD_REQUEST`: Missing `otp` or `deviceId`.
|
||||
|
||||
---
|
||||
|
||||
### B. Link Device (Admin Dashboard → Backend)
|
||||
|
||||
Used by the admin to match a device's OTP to a terminal.
|
||||
|
||||
* **URL**: `/api/terminals/{id}/link-device`
|
||||
* **Method**: `POST`
|
||||
* **Auth**: JWT (admin role)
|
||||
* **Headers**:
|
||||
* `Content-Type`: `application/json`
|
||||
* `Authorization`: `Bearer <jwt>`
|
||||
* **Path Parameters**:
|
||||
* `id` (Long): The terminal ID to link the device to.
|
||||
* **Request Body**:
|
||||
```json
|
||||
{
|
||||
"otp": "482916"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"message": "Device linked successfully",
|
||||
"terminalId": 1,
|
||||
"name": "Counter 1 Printer",
|
||||
"deviceId": "Esp 32 - AABBCCDDEEFF"
|
||||
}
|
||||
```
|
||||
|
||||
* **Response Codes**:
|
||||
* `200 OK`: Device linked successfully.
|
||||
* `400 BAD_REQUEST`: Invalid or expired OTP.
|
||||
|
||||
---
|
||||
|
||||
### C. Unpair Device (Admin Dashboard → Backend)
|
||||
|
||||
Used by the admin to disconnect a device from a terminal.
|
||||
|
||||
* **URL**: `/api/terminals/{id}/unpair`
|
||||
* **Method**: `POST`
|
||||
* **Auth**: JWT (admin role)
|
||||
|
||||
#### Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"message": "Device unpaired successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Operational Endpoints
|
||||
|
||||
### D. Get Order Details
|
||||
|
||||
Used by the ESP32 to fetch the order details after scanning a QR code.
|
||||
|
||||
* **URL**: `/api/terminals/orders/{orderNumber}`
|
||||
* **Method**: `GET`
|
||||
* **Headers**:
|
||||
* `X-API-KEY`: `<YOUR_TERMINAL_API_KEY>`
|
||||
* **Path Parameters**:
|
||||
* `orderNumber` (String): The order ID scanned from the QR code (e.g., `ORD-87A3B2D9`).
|
||||
|
||||
#### Response Codes:
|
||||
* `200 OK`: Order found and is eligible for processing.
|
||||
* `401 UNAUTHORIZED`: Invalid or missing `X-API-KEY`.
|
||||
* `404 NOT_FOUND`: Order number does not exist.
|
||||
* `400 BAD_REQUEST`: Order has already been fulfilled (`status` is `COMPLETED`).
|
||||
* `410 GONE`: Order has expired/archived.
|
||||
|
||||
#### Response Body (`200 OK` JSON Schema):
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"orderNumber": "ORD-68EF73C9",
|
||||
"displayOrderId": "045",
|
||||
"userId": 5,
|
||||
"totalAmount": 250.00,
|
||||
"status": "PAID",
|
||||
"paymentMethod": "RITZ_TOKEN",
|
||||
"createdAt": "2026-06-17T13:30:00",
|
||||
"orderType": "STORE_ORDER",
|
||||
"archived": false,
|
||||
"hasFeedback": false,
|
||||
"items": [
|
||||
{
|
||||
"id": 24,
|
||||
"productId": 3,
|
||||
"productName": "Masala Dosa",
|
||||
"price": 60.00,
|
||||
"quantity": 2,
|
||||
"stallId": 1,
|
||||
"stallName": "RIT Canteen"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"productId": 7,
|
||||
"productName": "Cold Coffee",
|
||||
"price": 40.00,
|
||||
"quantity": 1,
|
||||
"stallId": 2,
|
||||
"stallName": "Juice Bar"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### E. Validate API Key
|
||||
|
||||
Used by the ESP32 to verify its API key is still valid (optional health check).
|
||||
|
||||
* **URL**: `/api/terminals/validate`
|
||||
* **Method**: `GET`
|
||||
* **Headers**:
|
||||
* `X-API-KEY`: `<YOUR_TERMINAL_API_KEY>`
|
||||
|
||||
#### Response (`200 OK`):
|
||||
```json
|
||||
{
|
||||
"status": "VALID",
|
||||
"terminalId": 1,
|
||||
"name": "Counter 1 Printer",
|
||||
"location": "Main Hall"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### F. Device Logging
|
||||
|
||||
Used by the ESP32 to submit runtime system logs to the backend.
|
||||
|
||||
* **URL**: `/api/device-logs`
|
||||
* **Method**: `POST`
|
||||
* **Headers**:
|
||||
* `Content-Type`: `application/json`
|
||||
* **Request Body**:
|
||||
```json
|
||||
{
|
||||
"device_id": "Esp 32 - AABBCCDDEEFF",
|
||||
"message": "Firmware Version: 1.2.6"
|
||||
}
|
||||
```
|
||||
* **Response Codes**:
|
||||
* `201 CREATED`: Log successfully recorded.
|
||||
* `400 BAD_REQUEST`: Missing `device_id` or `message`.
|
||||
|
||||
---
|
||||
|
||||
### G. Verify Terminal PIN (Admin Tool)
|
||||
|
||||
Used during admin operations to reveal the terminal's API Key.
|
||||
|
||||
* **URL**: `/api/terminals/{id}/verify-pin`
|
||||
* **Method**: `POST`
|
||||
* **Headers**:
|
||||
* `Content-Type`: `application/json`
|
||||
* **Path Parameters**:
|
||||
* `id` (Long): The database ID of the terminal registration.
|
||||
* **Request Body**:
|
||||
```json
|
||||
{
|
||||
"pin": "1234"
|
||||
}
|
||||
```
|
||||
* **Response Body (`200 OK`):**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Counter 1 Printer",
|
||||
"location": "Main Hall",
|
||||
"pin": "1234",
|
||||
"apiKey": "POS-EF9832B743CA90B2381F",
|
||||
"deviceId": "Esp 32 - AABBCCDDEEFF",
|
||||
"paired": true,
|
||||
"pairedAt": "2026-06-17T14:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ESP32 Firmware Integration Guide
|
||||
|
||||
### Pairing Flow (`database.cpp`)
|
||||
|
||||
The ESP32 now uses OTP-based pairing instead of JWT token polling:
|
||||
|
||||
```cpp
|
||||
void setupPairing()
|
||||
{
|
||||
// 1. Generate 6-digit OTP
|
||||
currentOtp = generateUniqueOTP(oldOtp);
|
||||
displayStatus("OTP: " + currentOtp + "\nEnter on Admin Panel", 300);
|
||||
|
||||
// 2. POST OTP + deviceId to /api/terminals/pair
|
||||
JsonDocument requestDoc;
|
||||
requestDoc["otp"] = currentOtp;
|
||||
requestDoc["deviceId"] = getDeviceId();
|
||||
|
||||
// 3. Poll every 3 seconds — check if status == "PAIRED"
|
||||
// 4. On success, extract apiKey and save to NVS
|
||||
apiKey = doc["apiKey"].as<String>();
|
||||
saveApiKey(apiKey);
|
||||
ESP.restart();
|
||||
}
|
||||
```
|
||||
|
||||
### Data Fetching (`database.cpp`)
|
||||
|
||||
All requests use `X-API-KEY` instead of `Authorization: Bearer`:
|
||||
|
||||
```cpp
|
||||
String getData(String orderNumber)
|
||||
{
|
||||
httpClient.addHeader("X-API-KEY", apiKey); // Not Bearer token
|
||||
// ... GET /api/terminals/orders/{orderNumber}
|
||||
}
|
||||
```
|
||||
|
||||
### Order JSON Parsing (`bill_bot_system.cpp`)
|
||||
|
||||
The new backend returns order details at the **root level** (no `"data"` wrapper):
|
||||
|
||||
| Old Path | New Path |
|
||||
|---|---|
|
||||
| `doc["data"]["orderId"]` | `doc["displayOrderId"]` or `doc["orderNumber"]` |
|
||||
| `doc["data"]["status"]` | `doc["status"]` (`"PAID"` = ready, `"COMPLETED"` = already done) |
|
||||
| `doc["data"]["dateTz"]` | `doc["createdAt"]` |
|
||||
| `doc["data"]["order"]` (items) | `doc["items"]` |
|
||||
| `item["productInfo"]["name"]` | `item["productName"]` |
|
||||
| `item["counterId"]` | `item["stallId"]` / `item["stallName"]` |
|
||||
| `item["price"]` (in paise) | `item["price"]` (in rupees, no /100 needed) |
|
||||
|
||||
### Device Logging (`database.cpp`)
|
||||
|
||||
Logs now go to the Spring Boot backend directly (no Supabase):
|
||||
|
||||
```cpp
|
||||
void sendLog(String message)
|
||||
{
|
||||
httpClient.begin(logUrl); // /api/device-logs
|
||||
httpClient.addHeader("Content-Type", "application/json");
|
||||
// No supabaseApiKey header needed
|
||||
httpClient.POST(body);
|
||||
}
|
||||
```
|
||||
|
||||
### QR Code Format
|
||||
|
||||
The QR code payload is now the **order number directly**:
|
||||
- Old: `prefix$PAYMENT_ID$suffix` → parsed with `$` delimiters
|
||||
- New: `ORD-87A3B2D9` → passed directly to `getData()`
|
||||
Reference in New Issue
Block a user