dev #18
@@ -27,6 +27,7 @@ import java.util.UUID;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -36,6 +37,11 @@ import java.util.regex.Pattern;
|
|||||||
@RequestMapping("/api/orders")
|
@RequestMapping("/api/orders")
|
||||||
public class OrderController {
|
public class OrderController {
|
||||||
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
|
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
|
||||||
|
private static final Set<String> PERSONAL_DATA_REDACTED_STATUSES = Set.of(
|
||||||
|
"IN_PRODUCTION",
|
||||||
|
"SHIPPED",
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
|
||||||
private final OrderService orderService;
|
private final OrderService orderService;
|
||||||
private final OrderRepository orderRepo;
|
private final OrderRepository orderRepo;
|
||||||
@@ -292,10 +298,13 @@ public class OrderController {
|
|||||||
dto.setPaymentMethod(p.getMethod());
|
dto.setPaymentMethod(p.getMethod());
|
||||||
});
|
});
|
||||||
|
|
||||||
dto.setCustomerEmail(order.getCustomerEmail());
|
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
|
||||||
dto.setCustomerPhone(order.getCustomerPhone());
|
if (!redactPersonalData) {
|
||||||
|
dto.setCustomerEmail(order.getCustomerEmail());
|
||||||
|
dto.setCustomerPhone(order.getCustomerPhone());
|
||||||
|
dto.setBillingCustomerType(order.getBillingCustomerType());
|
||||||
|
}
|
||||||
dto.setPreferredLanguage(order.getPreferredLanguage());
|
dto.setPreferredLanguage(order.getPreferredLanguage());
|
||||||
dto.setBillingCustomerType(order.getBillingCustomerType());
|
|
||||||
dto.setCurrency(order.getCurrency());
|
dto.setCurrency(order.getCurrency());
|
||||||
dto.setSetupCostChf(order.getSetupCostChf());
|
dto.setSetupCostChf(order.getSetupCostChf());
|
||||||
dto.setShippingCostChf(order.getShippingCostChf());
|
dto.setShippingCostChf(order.getShippingCostChf());
|
||||||
@@ -310,30 +319,32 @@ public class OrderController {
|
|||||||
dto.setCreatedAt(order.getCreatedAt());
|
dto.setCreatedAt(order.getCreatedAt());
|
||||||
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
|
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
|
||||||
|
|
||||||
AddressDto billing = new AddressDto();
|
if (!redactPersonalData) {
|
||||||
billing.setFirstName(order.getBillingFirstName());
|
AddressDto billing = new AddressDto();
|
||||||
billing.setLastName(order.getBillingLastName());
|
billing.setFirstName(order.getBillingFirstName());
|
||||||
billing.setCompanyName(order.getBillingCompanyName());
|
billing.setLastName(order.getBillingLastName());
|
||||||
billing.setContactPerson(order.getBillingContactPerson());
|
billing.setCompanyName(order.getBillingCompanyName());
|
||||||
billing.setAddressLine1(order.getBillingAddressLine1());
|
billing.setContactPerson(order.getBillingContactPerson());
|
||||||
billing.setAddressLine2(order.getBillingAddressLine2());
|
billing.setAddressLine1(order.getBillingAddressLine1());
|
||||||
billing.setZip(order.getBillingZip());
|
billing.setAddressLine2(order.getBillingAddressLine2());
|
||||||
billing.setCity(order.getBillingCity());
|
billing.setZip(order.getBillingZip());
|
||||||
billing.setCountryCode(order.getBillingCountryCode());
|
billing.setCity(order.getBillingCity());
|
||||||
dto.setBillingAddress(billing);
|
billing.setCountryCode(order.getBillingCountryCode());
|
||||||
|
dto.setBillingAddress(billing);
|
||||||
|
|
||||||
if (!order.getShippingSameAsBilling()) {
|
if (!order.getShippingSameAsBilling()) {
|
||||||
AddressDto shipping = new AddressDto();
|
AddressDto shipping = new AddressDto();
|
||||||
shipping.setFirstName(order.getShippingFirstName());
|
shipping.setFirstName(order.getShippingFirstName());
|
||||||
shipping.setLastName(order.getShippingLastName());
|
shipping.setLastName(order.getShippingLastName());
|
||||||
shipping.setCompanyName(order.getShippingCompanyName());
|
shipping.setCompanyName(order.getShippingCompanyName());
|
||||||
shipping.setContactPerson(order.getShippingContactPerson());
|
shipping.setContactPerson(order.getShippingContactPerson());
|
||||||
shipping.setAddressLine1(order.getShippingAddressLine1());
|
shipping.setAddressLine1(order.getShippingAddressLine1());
|
||||||
shipping.setAddressLine2(order.getShippingAddressLine2());
|
shipping.setAddressLine2(order.getShippingAddressLine2());
|
||||||
shipping.setZip(order.getShippingZip());
|
shipping.setZip(order.getShippingZip());
|
||||||
shipping.setCity(order.getShippingCity());
|
shipping.setCity(order.getShippingCity());
|
||||||
shipping.setCountryCode(order.getShippingCountryCode());
|
shipping.setCountryCode(order.getShippingCountryCode());
|
||||||
dto.setShippingAddress(shipping);
|
dto.setShippingAddress(shipping);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<OrderItemDto> itemDtos = items.stream().map(i -> {
|
List<OrderItemDto> itemDtos = items.stream().map(i -> {
|
||||||
@@ -354,6 +365,13 @@ public class OrderController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldRedactPersonalData(String status) {
|
||||||
|
if (status == null || status.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
private String getDisplayOrderNumber(Order order) {
|
private String getDisplayOrderNumber(Order order) {
|
||||||
String orderNumber = order.getOrderNumber();
|
String orderNumber = order.getOrderNumber();
|
||||||
if (orderNumber != null && !orderNumber.isBlank()) {
|
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.printcalculator.entity.Order;
|
|||||||
import com.printcalculator.entity.OrderItem;
|
import com.printcalculator.entity.OrderItem;
|
||||||
import com.printcalculator.entity.Payment;
|
import com.printcalculator.entity.Payment;
|
||||||
import com.printcalculator.entity.QuoteSession;
|
import com.printcalculator.entity.QuoteSession;
|
||||||
|
import com.printcalculator.event.OrderShippedEvent;
|
||||||
import com.printcalculator.repository.OrderItemRepository;
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
import com.printcalculator.repository.OrderRepository;
|
import com.printcalculator.repository.OrderRepository;
|
||||||
import com.printcalculator.repository.PaymentRepository;
|
import com.printcalculator.repository.PaymentRepository;
|
||||||
@@ -15,6 +16,7 @@ import com.printcalculator.service.InvoicePdfRenderingService;
|
|||||||
import com.printcalculator.service.PaymentService;
|
import com.printcalculator.service.PaymentService;
|
||||||
import com.printcalculator.service.QrBillService;
|
import com.printcalculator.service.QrBillService;
|
||||||
import com.printcalculator.service.StorageService;
|
import com.printcalculator.service.StorageService;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.ContentDisposition;
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -61,6 +63,7 @@ public class AdminOrderController {
|
|||||||
private final StorageService storageService;
|
private final StorageService storageService;
|
||||||
private final InvoicePdfRenderingService invoiceService;
|
private final InvoicePdfRenderingService invoiceService;
|
||||||
private final QrBillService qrBillService;
|
private final QrBillService qrBillService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public AdminOrderController(
|
public AdminOrderController(
|
||||||
OrderRepository orderRepo,
|
OrderRepository orderRepo,
|
||||||
@@ -69,7 +72,8 @@ public class AdminOrderController {
|
|||||||
PaymentService paymentService,
|
PaymentService paymentService,
|
||||||
StorageService storageService,
|
StorageService storageService,
|
||||||
InvoicePdfRenderingService invoiceService,
|
InvoicePdfRenderingService invoiceService,
|
||||||
QrBillService qrBillService
|
QrBillService qrBillService,
|
||||||
|
ApplicationEventPublisher eventPublisher
|
||||||
) {
|
) {
|
||||||
this.orderRepo = orderRepo;
|
this.orderRepo = orderRepo;
|
||||||
this.orderItemRepo = orderItemRepo;
|
this.orderItemRepo = orderItemRepo;
|
||||||
@@ -78,6 +82,7 @@ public class AdminOrderController {
|
|||||||
this.storageService = storageService;
|
this.storageService = storageService;
|
||||||
this.invoiceService = invoiceService;
|
this.invoiceService = invoiceService;
|
||||||
this.qrBillService = qrBillService;
|
this.qrBillService = qrBillService;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -124,10 +129,16 @@ public class AdminOrderController {
|
|||||||
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
|
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
String previousStatus = order.getStatus();
|
||||||
order.setStatus(normalizedStatus);
|
order.setStatus(normalizedStatus);
|
||||||
orderRepo.save(order);
|
Order savedOrder = orderRepo.save(order);
|
||||||
|
|
||||||
return ResponseEntity.ok(toOrderDto(order));
|
// Notify customer only on transition to SHIPPED.
|
||||||
|
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
|
||||||
|
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toOrderDto(savedOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{orderId}/items/{orderItemId}/file")
|
@GetMapping("/{orderId}/items/{orderItemId}/file")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.printcalculator.event;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class OrderShippedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Order order;
|
||||||
|
|
||||||
|
public OrderShippedEvent(Object source, Order order) {
|
||||||
|
super(source);
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.printcalculator.entity.Order;
|
|||||||
import com.printcalculator.entity.OrderItem;
|
import com.printcalculator.entity.OrderItem;
|
||||||
import com.printcalculator.entity.Payment;
|
import com.printcalculator.entity.Payment;
|
||||||
import com.printcalculator.event.OrderCreatedEvent;
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
|
import com.printcalculator.event.OrderShippedEvent;
|
||||||
import com.printcalculator.event.PaymentConfirmedEvent;
|
import com.printcalculator.event.PaymentConfirmedEvent;
|
||||||
import com.printcalculator.event.PaymentReportedEvent;
|
import com.printcalculator.event.PaymentReportedEvent;
|
||||||
import com.printcalculator.repository.OrderItemRepository;
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
@@ -95,6 +96,19 @@ public class OrderEmailListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
public void handleOrderShippedEvent(OrderShippedEvent event) {
|
||||||
|
Order order = event.getOrder();
|
||||||
|
log.info("Processing OrderShippedEvent for order id: {}", order.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendOrderShippedEmail(order);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send order shipped email for order id: {}", order.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void sendCustomerConfirmationEmail(Order order) {
|
private void sendCustomerConfirmationEmail(Order order) {
|
||||||
String language = resolveLanguage(order.getPreferredLanguage());
|
String language = resolveLanguage(order.getPreferredLanguage());
|
||||||
String orderNumber = getDisplayOrderNumber(order);
|
String orderNumber = getDisplayOrderNumber(order);
|
||||||
@@ -153,6 +167,21 @@ public class OrderEmailListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendOrderShippedEmail(Order order) {
|
||||||
|
String language = resolveLanguage(order.getPreferredLanguage());
|
||||||
|
String orderNumber = getDisplayOrderNumber(order);
|
||||||
|
|
||||||
|
Map<String, Object> templateData = buildBaseTemplateData(order, language);
|
||||||
|
String subject = applyOrderShippedTexts(templateData, language, orderNumber);
|
||||||
|
|
||||||
|
emailNotificationService.sendEmail(
|
||||||
|
order.getCustomer().getEmail(),
|
||||||
|
subject,
|
||||||
|
"order-shipped",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private void sendAdminNotificationEmail(Order order) {
|
private void sendAdminNotificationEmail(Order order) {
|
||||||
String orderNumber = getDisplayOrderNumber(order);
|
String orderNumber = getDisplayOrderNumber(order);
|
||||||
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
|
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
|
||||||
@@ -381,6 +410,63 @@ public class OrderEmailListener {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String applyOrderShippedTexts(Map<String, Object> templateData, String language, String orderNumber) {
|
||||||
|
return switch (language) {
|
||||||
|
case "en" -> {
|
||||||
|
templateData.put("emailTitle", "Order Shipped");
|
||||||
|
templateData.put("headlineText", "Your order #" + orderNumber + " has been shipped");
|
||||||
|
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
|
||||||
|
templateData.put("introText", "Good news: your package has left our workshop and is on its way.");
|
||||||
|
templateData.put("statusText", "Current status: Shipped.");
|
||||||
|
templateData.put("orderDetailsCtaText", "View order status");
|
||||||
|
templateData.put("supportText", "If you need assistance, reply to this email.");
|
||||||
|
templateData.put("footerText", "Automated message from 3D-Fab.");
|
||||||
|
templateData.put("labelOrderNumber", "Order number");
|
||||||
|
templateData.put("labelTotal", "Total");
|
||||||
|
yield "Your order has been shipped (Order #" + orderNumber + ") - 3D-Fab";
|
||||||
|
}
|
||||||
|
case "de" -> {
|
||||||
|
templateData.put("emailTitle", "Bestellung versandt");
|
||||||
|
templateData.put("headlineText", "Ihre Bestellung #" + orderNumber + " wurde versandt");
|
||||||
|
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
|
||||||
|
templateData.put("introText", "Gute Nachricht: Ihr Paket hat unsere Werkstatt verlassen und ist unterwegs.");
|
||||||
|
templateData.put("statusText", "Aktueller Status: Versandt.");
|
||||||
|
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
|
||||||
|
templateData.put("supportText", "Wenn Sie Hilfe benoetigen, antworten Sie auf diese E-Mail.");
|
||||||
|
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
|
||||||
|
templateData.put("labelOrderNumber", "Bestellnummer");
|
||||||
|
templateData.put("labelTotal", "Gesamtbetrag");
|
||||||
|
yield "Ihre Bestellung wurde versandt (Bestellung #" + orderNumber + ") - 3D-Fab";
|
||||||
|
}
|
||||||
|
case "fr" -> {
|
||||||
|
templateData.put("emailTitle", "Commande expediee");
|
||||||
|
templateData.put("headlineText", "Votre commande #" + orderNumber + " a ete expediee");
|
||||||
|
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
|
||||||
|
templateData.put("introText", "Bonne nouvelle: votre colis a quitte notre atelier et est en route.");
|
||||||
|
templateData.put("statusText", "Statut actuel: Expediee.");
|
||||||
|
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
|
||||||
|
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
|
||||||
|
templateData.put("footerText", "Message automatique de 3D-Fab.");
|
||||||
|
templateData.put("labelOrderNumber", "Numero de commande");
|
||||||
|
templateData.put("labelTotal", "Total");
|
||||||
|
yield "Votre commande a ete expediee (Commande #" + orderNumber + ") - 3D-Fab";
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
templateData.put("emailTitle", "Ordine spedito");
|
||||||
|
templateData.put("headlineText", "Il tuo ordine #" + orderNumber + " e' stato spedito");
|
||||||
|
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
|
||||||
|
templateData.put("introText", "Buone notizie: il tuo pacco e' partito dal nostro laboratorio ed e' in viaggio.");
|
||||||
|
templateData.put("statusText", "Stato attuale: spedito.");
|
||||||
|
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
|
||||||
|
templateData.put("supportText", "Se hai bisogno di assistenza, rispondi a questa email.");
|
||||||
|
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
|
||||||
|
templateData.put("labelOrderNumber", "Numero ordine");
|
||||||
|
templateData.put("labelTotal", "Totale");
|
||||||
|
yield "Il tuo ordine e' stato spedito (Ordine #" + orderNumber + ") - 3D-Fab";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private String getDisplayOrderNumber(Order order) {
|
private String getDisplayOrderNumber(Order order) {
|
||||||
String orderNumber = order.getOrderNumber();
|
String orderNumber = order.getOrderNumber();
|
||||||
if (orderNumber != null && !orderNumber.isBlank()) {
|
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||||
|
|||||||
110
backend/src/main/resources/templates/email/order-shipped.html
Normal file
110
backend/src/main/resources/templates/email/order-shipped.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title th:text="${emailTitle}">Order Shipped</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: #555555;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-box {
|
||||||
|
background-color: #e9f3ff;
|
||||||
|
border: 1px solid #a9c8ef;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-box {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-box th {
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 18px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #999999;
|
||||||
|
margin-top: 30px;
|
||||||
|
border-top: 1px solid #eeeeee;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p th:text="${greetingText}">Hi Customer,</p>
|
||||||
|
<p th:text="${introText}">Good news: your package is on its way.</p>
|
||||||
|
|
||||||
|
<div class="status-box">
|
||||||
|
<strong th:text="${statusText}">Current status: Shipped.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-box">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th th:text="${labelOrderNumber}">Order number</th>
|
||||||
|
<td th:text="${orderNumber}">00000000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th th:text="${labelTotal}">Total</th>
|
||||||
|
<td th:text="${totalCost}">CHF 0.00</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span th:text="${orderDetailsCtaText}">View order status</span>:
|
||||||
|
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
|
||||||
|
</p>
|
||||||
|
<p th:text="${supportText}">If you need assistance, reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>© <span th:text="${currentYear}">2026</span> 3D-Fab</p>
|
||||||
|
<p th:text="${footerText}">Automated message.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.OrderDto;
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.repository.CustomerRepository;
|
||||||
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
|
import com.printcalculator.repository.OrderRepository;
|
||||||
|
import com.printcalculator.repository.PaymentRepository;
|
||||||
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
|
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||||
|
import com.printcalculator.service.OrderService;
|
||||||
|
import com.printcalculator.service.PaymentService;
|
||||||
|
import com.printcalculator.service.QrBillService;
|
||||||
|
import com.printcalculator.service.StorageService;
|
||||||
|
import com.printcalculator.service.TwintPaymentService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class OrderControllerPrivacyTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OrderService orderService;
|
||||||
|
@Mock
|
||||||
|
private OrderRepository orderRepo;
|
||||||
|
@Mock
|
||||||
|
private OrderItemRepository orderItemRepo;
|
||||||
|
@Mock
|
||||||
|
private QuoteSessionRepository quoteSessionRepo;
|
||||||
|
@Mock
|
||||||
|
private QuoteLineItemRepository quoteLineItemRepo;
|
||||||
|
@Mock
|
||||||
|
private CustomerRepository customerRepo;
|
||||||
|
@Mock
|
||||||
|
private StorageService storageService;
|
||||||
|
@Mock
|
||||||
|
private InvoicePdfRenderingService invoiceService;
|
||||||
|
@Mock
|
||||||
|
private QrBillService qrBillService;
|
||||||
|
@Mock
|
||||||
|
private TwintPaymentService twintPaymentService;
|
||||||
|
@Mock
|
||||||
|
private PaymentService paymentService;
|
||||||
|
@Mock
|
||||||
|
private PaymentRepository paymentRepo;
|
||||||
|
|
||||||
|
private OrderController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
controller = new OrderController(
|
||||||
|
orderService,
|
||||||
|
orderRepo,
|
||||||
|
orderItemRepo,
|
||||||
|
quoteSessionRepo,
|
||||||
|
quoteLineItemRepo,
|
||||||
|
customerRepo,
|
||||||
|
storageService,
|
||||||
|
invoiceService,
|
||||||
|
qrBillService,
|
||||||
|
twintPaymentService,
|
||||||
|
paymentService,
|
||||||
|
paymentRepo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrder_pendingPayment_keepsPersonalData() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = buildOrder(orderId, "PENDING_PAYMENT");
|
||||||
|
|
||||||
|
when(orderRepo.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of());
|
||||||
|
when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
ResponseEntity<OrderDto> response = controller.getOrder(orderId);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
assertNotNull(response.getBody());
|
||||||
|
assertEquals("customer@example.com", response.getBody().getCustomerEmail());
|
||||||
|
assertEquals("+41790000000", response.getBody().getCustomerPhone());
|
||||||
|
assertNotNull(response.getBody().getBillingAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrder_advancedStatuses_redactsPersonalData() {
|
||||||
|
List<String> statuses = List.of("IN_PRODUCTION", "SHIPPED", "COMPLETED");
|
||||||
|
|
||||||
|
for (String status : statuses) {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = buildOrder(orderId, status);
|
||||||
|
|
||||||
|
when(orderRepo.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderItemRepo.findByOrder_Id(orderId)).thenReturn(List.of());
|
||||||
|
when(paymentRepo.findByOrder_Id(orderId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
ResponseEntity<OrderDto> response = controller.getOrder(orderId);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
assertNotNull(response.getBody());
|
||||||
|
assertNull(response.getBody().getCustomerEmail());
|
||||||
|
assertNull(response.getBody().getCustomerPhone());
|
||||||
|
assertNull(response.getBody().getBillingCustomerType());
|
||||||
|
assertNull(response.getBody().getBillingAddress());
|
||||||
|
assertNull(response.getBody().getShippingAddress());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Order buildOrder(UUID orderId, String status) {
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(status);
|
||||||
|
order.setCustomerEmail("customer@example.com");
|
||||||
|
order.setCustomerPhone("+41790000000");
|
||||||
|
order.setBillingCustomerType("PRIVATE");
|
||||||
|
order.setBillingFirstName("Joe");
|
||||||
|
order.setBillingLastName("Kung");
|
||||||
|
order.setBillingAddressLine1("Via G. Pioda 1");
|
||||||
|
order.setBillingZip("6900");
|
||||||
|
order.setBillingCity("Lugano");
|
||||||
|
order.setBillingCountryCode("CH");
|
||||||
|
order.setShippingSameAsBilling(true);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -47,6 +48,8 @@ class AdminOrderControllerStatusValidationTest {
|
|||||||
private InvoicePdfRenderingService invoicePdfRenderingService;
|
private InvoicePdfRenderingService invoicePdfRenderingService;
|
||||||
@Mock
|
@Mock
|
||||||
private QrBillService qrBillService;
|
private QrBillService qrBillService;
|
||||||
|
@Mock
|
||||||
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
private AdminOrderController controller;
|
private AdminOrderController controller;
|
||||||
|
|
||||||
@@ -59,7 +62,8 @@ class AdminOrderControllerStatusValidationTest {
|
|||||||
paymentService,
|
paymentService,
|
||||||
storageService,
|
storageService,
|
||||||
invoicePdfRenderingService,
|
invoicePdfRenderingService,
|
||||||
qrBillService
|
qrBillService,
|
||||||
|
eventPublisher
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +96,7 @@ class AdminOrderControllerStatusValidationTest {
|
|||||||
order.setStatus("PENDING_PAYMENT");
|
order.setStatus("PENDING_PAYMENT");
|
||||||
|
|
||||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of());
|
when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of());
|
||||||
when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty());
|
when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
|||||||
123
security_best_practices_report.md
Normal file
123
security_best_practices_report.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Security Best Practices Report
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
Revisione sicurezza del progetto `print-calculator` (backend Spring Boot Java + frontend Angular/TypeScript) con focus su autenticazione/autorizzazione, esposizione dati, upload file, hardening e resilienza.
|
||||||
|
|
||||||
|
Risultato: **6 finding** totali.
|
||||||
|
|
||||||
|
- **Critical**: 1
|
||||||
|
- **High**: 3
|
||||||
|
- **Medium**: 2
|
||||||
|
|
||||||
|
Rischio principale: API pubbliche basate su UUID senza controllo di ownership/token, che consentono lettura PII e azioni di business su ordini.
|
||||||
|
|
||||||
|
## Scope e metodo
|
||||||
|
|
||||||
|
- Codice analizzato: backend (`backend/src/main/java`, `backend/src/main/resources`), frontend (`frontend/src/app`), config deploy (`deploy/`, `docker-compose*.yml`).
|
||||||
|
- Riferimenti skill usati: `javascript-general-web-frontend-security.md`.
|
||||||
|
- Nota: non è presente un riferimento specifico Java/Spring nel set dello skill; per il backend sono state applicate best practice consolidate Spring/security engineering.
|
||||||
|
|
||||||
|
## Critical findings
|
||||||
|
|
||||||
|
### SBP-001 - Broken access control su API ordine pubbliche (lettura PII + azioni stato)
|
||||||
|
|
||||||
|
- **Severity**: Critical
|
||||||
|
- **Impatto**: Chiunque ottenga un `orderId` può leggere dati personali ordine e invocare operazioni di business senza autenticazione.
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:131` (`GET /api/orders/{orderId}`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:141` (`POST /api/orders/{orderId}/payments/report`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:93` (`POST /api/orders/{orderId}/items/{orderItemId}/file`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/OrderController.java:295`-`337` (PII completa nel DTO: email, telefono, indirizzi billing/shipping).
|
||||||
|
- `backend/src/main/java/com/printcalculator/service/PaymentService.java:53`-`75` (cambio stato pagamento a `REPORTED`).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- Assenza di autenticazione/authorization applicativa su endpoint ordine.
|
||||||
|
- Modello “capability by UUID” senza token secondario, expiry o binding utente.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Introdurre un `order_access_token` random ad alta entropia (>=128 bit), memorizzato hashato e richiesto sugli endpoint pubblici ordine.
|
||||||
|
- Separare endpoint pubblici (minimo set dati) da endpoint interni/admin.
|
||||||
|
- Rimuovere `orderItemId` e dettagli sensibili dal DTO pubblico, o usare URL firmate a scadenza per upload/download.
|
||||||
|
- Valutare auth customer leggera (magic link OTP) per consultazione/modifica ordine.
|
||||||
|
|
||||||
|
## High findings
|
||||||
|
|
||||||
|
### SBP-002 - Esposizione PII su endpoint pubblico custom quote request
|
||||||
|
|
||||||
|
- **Severity**: High
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java:188`-`193` (`GET /api/custom-quote-requests/{id}` senza auth).
|
||||||
|
- `backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java:24`-`40` (campi PII e messaggio cliente).
|
||||||
|
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (`.anyRequest().permitAll()`).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- Endpoint “lookup by UUID” ritorna oggetto completo con dati personali.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Proteggere endpoint con token di accesso separato per richiesta (non solo UUID).
|
||||||
|
- Restituire una vista redatta/minimale per endpoint pubblici.
|
||||||
|
- Se endpoint non usato dal frontend, rimuoverlo.
|
||||||
|
|
||||||
|
### SBP-003 - Antivirus in fail-open + default scanner disattivato
|
||||||
|
|
||||||
|
- **Severity**: High
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/resources/application.properties:27` (`clamav.enabled=${CLAMAV_ENABLED:false}`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/service/ClamAVService.java:42`-`43` (scanner disabilitato => ritorna `true`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/service/ClamAVService.java:54`-`61` (errori scanner => `FAIL-OPEN`).
|
||||||
|
- `backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java:59`-`60` (eccezioni scanner ignorate, file mantenuto).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- File malevoli possono essere accettati quando scanner è down/non configurato.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Policy fail-closed in ambienti non-dev (`reject on scan error`).
|
||||||
|
- Rendere `CLAMAV_ENABLED=true` default in deploy runtime e bloccare startup se scanner richiesto ma non raggiungibile.
|
||||||
|
- Telemetria/alerting su scan bypass e failure rate.
|
||||||
|
|
||||||
|
### SBP-004 - Endpoint costosi esposti senza throttling/rate limit (DoS applicativo)
|
||||||
|
|
||||||
|
- **Severity**: High
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:36` (endpoint pubblici permessi globalmente).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/QuoteController.java:38`-`39` (`POST /api/quote` pubblico).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:114`-`120` (`POST /api/quote-sessions/{id}/line-items` pubblico).
|
||||||
|
- `backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java:228`-`235` (invocazione slicing).
|
||||||
|
- `backend/src/main/java/com/printcalculator/service/SlicerService.java:156`-`163` (job fino a 5 minuti).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- Upload/slicing massivo può saturare CPU, I/O e worker thread.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Rate limiting per IP/fingerprint/session (anche lato reverse proxy).
|
||||||
|
- Coda asincrona con limiti di concorrenza e timeout più stretti.
|
||||||
|
- Quote per utente/sessione e limite richieste per finestra temporale.
|
||||||
|
- CAPTCHA o proof-of-work per endpoint anonimi ad alto costo.
|
||||||
|
|
||||||
|
## Medium findings
|
||||||
|
|
||||||
|
### SBP-005 - Secret/default credenziali deboli nel codice di configurazione
|
||||||
|
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/resources/application.properties:7` (`DB_PASSWORD` fallback `printcalc_secret`).
|
||||||
|
- `backend/src/main/resources/application-local.properties:7`-`8` (admin password/secret hardcoded per profilo local).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- In caso di misconfigurazione ambientale o uso improprio profilo, vengono usati valori prevedibili.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Rimuovere fallback sensibili e rendere obbligatori i secret a startup.
|
||||||
|
- Spostare credenziali locali in file non versionato (`.env.local`, `.gitignore`) con template placeholder.
|
||||||
|
- Policy di secret rotation periodica.
|
||||||
|
|
||||||
|
### SBP-006 - CSRF disabilitato globalmente con autenticazione admin basata su cookie
|
||||||
|
|
||||||
|
- **Severity**: Medium
|
||||||
|
- **Evidenze**:
|
||||||
|
- `backend/src/main/java/com/printcalculator/config/SecurityConfig.java:24` (CSRF disabilitato globalmente).
|
||||||
|
- `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:129`-`136` (cookie sessione admin).
|
||||||
|
- `backend/src/main/java/com/printcalculator/security/AdminSessionService.java:133`-`134` (`Secure` + `SameSite=Strict` presenti, mitigazione parziale).
|
||||||
|
- **Rischio tecnico**:
|
||||||
|
- Con auth cookie-based, la protezione CSRF andrebbe mantenuta sugli endpoint state-changing admin; `SameSite=Strict` riduce ma non elimina tutti i vettori.
|
||||||
|
- **Fix raccomandato**:
|
||||||
|
- Riabilitare CSRF almeno su `/api/admin/**` e usare token CSRF (double-submit o synchronizer token).
|
||||||
|
- Mantenere `SameSite=Strict` come difesa aggiuntiva.
|
||||||
|
|
||||||
|
## Note e assunzioni
|
||||||
|
|
||||||
|
- Alcuni endpoint pubblici sembrano progettati come flusso anonimo customer; il finding resta valido perché manca una seconda prova di possesso oltre all’UUID.
|
||||||
|
- Non è stata eseguita una DAST esterna o pentest black-box; analisi effettuata su codice statico e configurazioni nel repository.
|
||||||
|
|
||||||
Reference in New Issue
Block a user