| 1 | package com.rbctcsworld.ecommerce.notification; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.auth.AppUser; | |
| 4 | import com.rbctcsworld.ecommerce.auth.UserRepository; | |
| 5 | import com.rbctcsworld.ecommerce.common.exception.ConflictException; | |
| 6 | import com.rbctcsworld.ecommerce.order.CustomerOrder; | |
| 7 | import com.rbctcsworld.ecommerce.order.OrderItem; | |
| 8 | import com.rbctcsworld.ecommerce.order.OrderRepository; | |
| 9 | import org.springframework.context.event.EventListener; | |
| 10 | import org.springframework.stereotype.Service; | |
| 11 | import org.springframework.transaction.annotation.Propagation; | |
| 12 | import org.springframework.transaction.annotation.Transactional; | |
| 13 | ||
| 14 | import java.math.BigDecimal; | |
| 15 | import java.math.RoundingMode; | |
| 16 | import java.time.Clock; | |
| 17 | import java.time.LocalDateTime; | |
| 18 | import java.util.List; | |
| 19 | import java.util.stream.Collectors; | |
| 20 | ||
| 21 | /** | |
| 22 | * Writes customer e-mails into the outbox (table notifications) and lets admins see and re-queue them. | |
| 23 | * | |
| 24 | * The listener runs synchronously INSIDE the transaction that published the event (MANDATORY): the e-mail | |
| 25 | * row is committed together with the order change, or not at all. Sending happens later, outside that | |
| 26 | * transaction (NotificationDeliveryService), so a slow or broken mail server never blocks a checkout. | |
| 27 | */ | |
| 28 | @Service | |
| 29 | public class NotificationService { | |
| 30 | ||
| 31 | record Email(String subject, String body) { | |
| 32 | } | |
| 33 | ||
| 34 | private final NotificationRepository notifications; | |
| 35 | private final OrderRepository orders; | |
| 36 | private final UserRepository users; | |
| 37 | private final Clock clock; | |
| 38 | ||
| 39 | public NotificationService(NotificationRepository notifications, OrderRepository orders, UserRepository users, | |
| 40 | Clock clock) { | |
| 41 | this.notifications = notifications; | |
| 42 | this.orders = orders; | |
| 43 | this.users = users; | |
| 44 | this.clock = clock; | |
| 45 | } | |
| 46 | ||
| 47 | @EventListener | |
| 48 | @Transactional(propagation = Propagation.MANDATORY) | |
| 49 | public void on(OrderNotificationEvent event) { | |
| 50 | CustomerOrder order = orders.findById(event.orderId()).orElseThrow(); | |
| 51 | String recipient = users.findById(order.getUserId()).map(AppUser::getEmail).orElse(null); | |
| 52 |
1
1. on : removed conditional - replaced equality check with false → KILLED |
if (recipient == null) return; // account deleted: nobody to tell |
| 53 | Email email = compose(event, order); | |
| 54 | notifications.save(new Notification(order.getId(), recipient, event.type().name(), email.subject(), | |
| 55 | email.body(), LocalDateTime.now(clock))); | |
| 56 | } | |
| 57 | ||
| 58 | /** Account e-mails (no order): queued in the same transaction as the account change. */ | |
| 59 | @EventListener | |
| 60 | @Transactional(propagation = Propagation.MANDATORY) | |
| 61 | public void on(AccountEmailEvent event) { | |
| 62 | notifications.save(new Notification(null, event.recipient(), event.type().name(), event.subject(), | |
| 63 | event.body(), LocalDateTime.now(clock))); | |
| 64 | } | |
| 65 | ||
| 66 | /** Plain-text e-mails: readable in every mail client, nothing to render or sanitise. */ | |
| 67 | static Email compose(OrderNotificationEvent event, CustomerOrder o) { | |
| 68 | String no = o.getOrderNumber(); | |
| 69 |
1
1. compose : replaced return value with null for com/rbctcsworld/ecommerce/notification/NotificationService::compose → KILLED |
return switch (event.type()) { |
| 70 | case ORDER_PLACED -> new Email("Order " + no + " received - " + money(o.getTotal()), | |
| 71 | "Thank you for your order " + no + ".\n\n" + lines(o.getItems()) | |
| 72 | + "\n" + totals(o) + shipsTo(o) | |
| 73 | + "\nNext step: pay the order in My orders. We reserve the items for you."); | |
| 74 | case PAYMENT_RECEIVED -> new Email("Payment received for order " + no, | |
| 75 | "We received your payment of " + money(o.getTotal()) + " for order " + no | |
| 76 | + ".\nWe will e-mail you again when it ships."); | |
| 77 | case ORDER_SHIPPED -> new Email("Order " + no + " is on its way", | |
| 78 | "Order " + no + " has shipped with " + o.getCarrier() + ".\nTracking number: " | |
| 79 | + o.getTrackingNumber() + "\nYou can follow it under My orders."); | |
| 80 | case ORDER_DELIVERED -> new Email("Order " + no + " was delivered", | |
| 81 | "Order " + no + " was delivered. Enjoy!\nSomething wrong? You can request a return within 30 days" | |
| 82 | + " under My orders."); | |
| 83 | case ORDER_CANCELLED -> new Email("Order " + no + " was cancelled", | |
| 84 |
1
1. compose : removed conditional - replaced equality check with false → KILLED |
"Order " + no + " was cancelled." + (event.amount() == null ? "" |
| 85 | : "\nWe refunded " + money(event.amount()) + " to your card; it can take a few days to appear.")); | |
| 86 | case RETURN_APPROVED -> new Email("Return approved for order " + no, | |
| 87 | "Your return for order " + no + " was approved.\nRefund: " + money(event.amount()) | |
| 88 | + " to your card; it can take a few days to appear."); | |
| 89 | case RETURN_REJECTED -> new Email("Return for order " + no + " was not approved", | |
| 90 | "Your return for order " + no + " was not approved. Questions? Reply to this e-mail."); | |
| 91 | }; | |
| 92 | } | |
| 93 | ||
| 94 | static String money(BigDecimal amount) { | |
| 95 |
1
1. money : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::money → KILLED |
return "$" + amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); |
| 96 | } | |
| 97 | ||
| 98 | private static String lines(List<OrderItem> items) { | |
| 99 |
1
1. lines : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lines → KILLED |
return items.stream() |
| 100 |
1
1. lambda$lines$0 : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lambda$lines$0 → KILLED |
.map(i -> " " + i.getQuantity() + " x " + i.getProductName() + " " + money(i.getLineTotal())) |
| 101 | .collect(Collectors.joining("\n")) + "\n"; | |
| 102 | } | |
| 103 | ||
| 104 | /** "Ships to: ..." - no phone number in an e-mail; empty for orders placed before addresses existed. */ | |
| 105 | private static String shipsTo(CustomerOrder o) { | |
| 106 |
2
1. shipsTo : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::shipsTo → KILLED 2. shipsTo : removed conditional - replaced equality check with false → KILLED |
return o.getShippingAddress() == null ? "" : "Ships to: " + o.getShippingAddress().oneLine() + "\n"; |
| 107 | } | |
| 108 | ||
| 109 | private static String totals(CustomerOrder o) { | |
| 110 | StringBuilder s = new StringBuilder("Subtotal " + money(o.getSubtotal()) + "\n"); | |
| 111 |
2
1. totals : removed conditional - replaced comparison check with false → KILLED 2. totals : changed conditional boundary → KILLED |
if (o.getDiscount().signum() > 0) { |
| 112 | s.append("Discount -").append(money(o.getDiscount())).append(" (").append(o.getCouponCode()).append(")\n"); | |
| 113 | } | |
| 114 |
1
1. totals : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::totals → KILLED |
return s.append("Shipping ").append(money(o.getShippingFee())).append("\nTax ").append(money(o.getTax())) |
| 115 | .append("\nTotal ").append(money(o.getTotal())).append("\n").toString(); | |
| 116 | } | |
| 117 | ||
| 118 | // ---------------------------------------------------------------- admin | |
| 119 | ||
| 120 | @Transactional(readOnly = true) | |
| 121 | public List<Notification> list(String status, Long orderId) { | |
| 122 |
2
1. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED 2. list : removed conditional - replaced equality check with false → KILLED |
if (orderId != null) return notifications.findByOrderIdOrderByIdAsc(orderId); |
| 123 |
3
1. list : removed conditional - replaced equality check with false → KILLED 2. list : removed conditional - replaced equality check with false → KILLED 3. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED |
if (status != null && !status.isBlank()) return notifications.findTop100ByStatusOrderByIdDesc(status.trim().toUpperCase()); |
| 124 |
1
1. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED |
return notifications.findTop100ByOrderByIdDesc(); |
| 125 | } | |
| 126 | ||
| 127 | /** 409 unless FAILED: a pending or sent e-mail is never sent twice by an admin click. */ | |
| 128 | @Transactional | |
| 129 | public void retry(Long id) { | |
| 130 |
1
1. retry : removed conditional - replaced equality check with false → KILLED |
if (notifications.requeue(id, LocalDateTime.now(clock)) == 0) { |
| 131 | throw new ConflictException("Only a FAILED notification can be sent again"); | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
Mutations | ||
| 52 |
1.1 |
|
| 69 |
1.1 |
|
| 84 |
1.1 |
|
| 95 |
1.1 |
|
| 99 |
1.1 |
|
| 100 |
1.1 |
|
| 106 |
1.1 2.2 |
|
| 111 |
1.1 2.2 |
|
| 114 |
1.1 |
|
| 122 |
1.1 2.2 |
|
| 123 |
1.1 2.2 3.3 |
|
| 124 |
1.1 |
|
| 130 |
1.1 |