BillBot Code added
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user