feat(back-end and front-end) email
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 35s
Build and Deploy / build-and-push (push) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 12s
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 35s
Build and Deploy / build-and-push (push) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 12s
This commit is contained in:
@@ -27,6 +27,7 @@ import java.util.UUID;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
@@ -36,6 +37,11 @@ import java.util.regex.Pattern;
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderController {
|
||||
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 OrderRepository orderRepo;
|
||||
@@ -292,10 +298,13 @@ public class OrderController {
|
||||
dto.setPaymentMethod(p.getMethod());
|
||||
});
|
||||
|
||||
dto.setCustomerEmail(order.getCustomerEmail());
|
||||
dto.setCustomerPhone(order.getCustomerPhone());
|
||||
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
|
||||
if (!redactPersonalData) {
|
||||
dto.setCustomerEmail(order.getCustomerEmail());
|
||||
dto.setCustomerPhone(order.getCustomerPhone());
|
||||
dto.setBillingCustomerType(order.getBillingCustomerType());
|
||||
}
|
||||
dto.setPreferredLanguage(order.getPreferredLanguage());
|
||||
dto.setBillingCustomerType(order.getBillingCustomerType());
|
||||
dto.setCurrency(order.getCurrency());
|
||||
dto.setSetupCostChf(order.getSetupCostChf());
|
||||
dto.setShippingCostChf(order.getShippingCostChf());
|
||||
@@ -310,30 +319,32 @@ public class OrderController {
|
||||
dto.setCreatedAt(order.getCreatedAt());
|
||||
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
|
||||
|
||||
AddressDto billing = new AddressDto();
|
||||
billing.setFirstName(order.getBillingFirstName());
|
||||
billing.setLastName(order.getBillingLastName());
|
||||
billing.setCompanyName(order.getBillingCompanyName());
|
||||
billing.setContactPerson(order.getBillingContactPerson());
|
||||
billing.setAddressLine1(order.getBillingAddressLine1());
|
||||
billing.setAddressLine2(order.getBillingAddressLine2());
|
||||
billing.setZip(order.getBillingZip());
|
||||
billing.setCity(order.getBillingCity());
|
||||
billing.setCountryCode(order.getBillingCountryCode());
|
||||
dto.setBillingAddress(billing);
|
||||
if (!redactPersonalData) {
|
||||
AddressDto billing = new AddressDto();
|
||||
billing.setFirstName(order.getBillingFirstName());
|
||||
billing.setLastName(order.getBillingLastName());
|
||||
billing.setCompanyName(order.getBillingCompanyName());
|
||||
billing.setContactPerson(order.getBillingContactPerson());
|
||||
billing.setAddressLine1(order.getBillingAddressLine1());
|
||||
billing.setAddressLine2(order.getBillingAddressLine2());
|
||||
billing.setZip(order.getBillingZip());
|
||||
billing.setCity(order.getBillingCity());
|
||||
billing.setCountryCode(order.getBillingCountryCode());
|
||||
dto.setBillingAddress(billing);
|
||||
|
||||
if (!order.getShippingSameAsBilling()) {
|
||||
AddressDto shipping = new AddressDto();
|
||||
shipping.setFirstName(order.getShippingFirstName());
|
||||
shipping.setLastName(order.getShippingLastName());
|
||||
shipping.setCompanyName(order.getShippingCompanyName());
|
||||
shipping.setContactPerson(order.getShippingContactPerson());
|
||||
shipping.setAddressLine1(order.getShippingAddressLine1());
|
||||
shipping.setAddressLine2(order.getShippingAddressLine2());
|
||||
shipping.setZip(order.getShippingZip());
|
||||
shipping.setCity(order.getShippingCity());
|
||||
shipping.setCountryCode(order.getShippingCountryCode());
|
||||
dto.setShippingAddress(shipping);
|
||||
if (!order.getShippingSameAsBilling()) {
|
||||
AddressDto shipping = new AddressDto();
|
||||
shipping.setFirstName(order.getShippingFirstName());
|
||||
shipping.setLastName(order.getShippingLastName());
|
||||
shipping.setCompanyName(order.getShippingCompanyName());
|
||||
shipping.setContactPerson(order.getShippingContactPerson());
|
||||
shipping.setAddressLine1(order.getShippingAddressLine1());
|
||||
shipping.setAddressLine2(order.getShippingAddressLine2());
|
||||
shipping.setZip(order.getShippingZip());
|
||||
shipping.setCity(order.getShippingCity());
|
||||
shipping.setCountryCode(order.getShippingCountryCode());
|
||||
dto.setShippingAddress(shipping);
|
||||
}
|
||||
}
|
||||
|
||||
List<OrderItemDto> itemDtos = items.stream().map(i -> {
|
||||
@@ -354,6 +365,13 @@ public class OrderController {
|
||||
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) {
|
||||
String orderNumber = order.getOrderNumber();
|
||||
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.printcalculator.entity.Order;
|
||||
import com.printcalculator.entity.OrderItem;
|
||||
import com.printcalculator.entity.Payment;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.event.OrderShippedEvent;
|
||||
import com.printcalculator.repository.OrderItemRepository;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.PaymentRepository;
|
||||
@@ -15,6 +16,7 @@ import com.printcalculator.service.InvoicePdfRenderingService;
|
||||
import com.printcalculator.service.PaymentService;
|
||||
import com.printcalculator.service.QrBillService;
|
||||
import com.printcalculator.service.StorageService;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -61,6 +63,7 @@ public class AdminOrderController {
|
||||
private final StorageService storageService;
|
||||
private final InvoicePdfRenderingService invoiceService;
|
||||
private final QrBillService qrBillService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public AdminOrderController(
|
||||
OrderRepository orderRepo,
|
||||
@@ -69,7 +72,8 @@ public class AdminOrderController {
|
||||
PaymentService paymentService,
|
||||
StorageService storageService,
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService
|
||||
QrBillService qrBillService,
|
||||
ApplicationEventPublisher eventPublisher
|
||||
) {
|
||||
this.orderRepo = orderRepo;
|
||||
this.orderItemRepo = orderItemRepo;
|
||||
@@ -78,6 +82,7 @@ public class AdminOrderController {
|
||||
this.storageService = storageService;
|
||||
this.invoiceService = invoiceService;
|
||||
this.qrBillService = qrBillService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -124,10 +129,16 @@ public class AdminOrderController {
|
||||
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
|
||||
);
|
||||
}
|
||||
String previousStatus = order.getStatus();
|
||||
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")
|
||||
|
||||
@@ -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.Payment;
|
||||
import com.printcalculator.event.OrderCreatedEvent;
|
||||
import com.printcalculator.event.OrderShippedEvent;
|
||||
import com.printcalculator.event.PaymentConfirmedEvent;
|
||||
import com.printcalculator.event.PaymentReportedEvent;
|
||||
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) {
|
||||
String language = resolveLanguage(order.getPreferredLanguage());
|
||||
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) {
|
||||
String orderNumber = getDisplayOrderNumber(order);
|
||||
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) {
|
||||
String orderNumber = order.getOrderNumber();
|
||||
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>
|
||||
Reference in New Issue
Block a user