NotificationService.java

package com.rbctcsworld.ecommerce.notification;

import com.rbctcsworld.ecommerce.auth.AppUser;
import com.rbctcsworld.ecommerce.auth.UserRepository;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.order.CustomerOrder;
import com.rbctcsworld.ecommerce.order.OrderItem;
import com.rbctcsworld.ecommerce.order.OrderRepository;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

/**
 * Writes customer e-mails into the outbox (table notifications) and lets admins see and re-queue them.
 *
 * The listener runs synchronously INSIDE the transaction that published the event (MANDATORY): the e-mail
 * row is committed together with the order change, or not at all. Sending happens later, outside that
 * transaction (NotificationDeliveryService), so a slow or broken mail server never blocks a checkout.
 */
@Service
public class NotificationService {

    record Email(String subject, String body) {
    }

    private final NotificationRepository notifications;
    private final OrderRepository orders;
    private final UserRepository users;
    private final Clock clock;

    public NotificationService(NotificationRepository notifications, OrderRepository orders, UserRepository users,
                               Clock clock) {
        this.notifications = notifications;
        this.orders = orders;
        this.users = users;
        this.clock = clock;
    }

    @EventListener
    @Transactional(propagation = Propagation.MANDATORY)
    public void on(OrderNotificationEvent event) {
        CustomerOrder order = orders.findById(event.orderId()).orElseThrow();
        String recipient = users.findById(order.getUserId()).map(AppUser::getEmail).orElse(null);
        if (recipient == null) return;                               // account deleted: nobody to tell
        Email email = compose(event, order);
        notifications.save(new Notification(order.getId(), recipient, event.type().name(), email.subject(),
                email.body(), LocalDateTime.now(clock)));
    }

    /** Account e-mails (no order): queued in the same transaction as the account change. */
    @EventListener
    @Transactional(propagation = Propagation.MANDATORY)
    public void on(AccountEmailEvent event) {
        notifications.save(new Notification(null, event.recipient(), event.type().name(), event.subject(),
                event.body(), LocalDateTime.now(clock)));
    }

    /** Plain-text e-mails: readable in every mail client, nothing to render or sanitise. */
    static Email compose(OrderNotificationEvent event, CustomerOrder o) {
        String no = o.getOrderNumber();
        return switch (event.type()) {
            case ORDER_PLACED -> new Email("Order " + no + " received - " + money(o.getTotal()),
                    "Thank you for your order " + no + ".\n\n" + lines(o.getItems())
                            + "\n" + totals(o) + shipsTo(o)
                            + "\nNext step: pay the order in My orders. We reserve the items for you.");
            case PAYMENT_RECEIVED -> new Email("Payment received for order " + no,
                    "We received your payment of " + money(o.getTotal()) + " for order " + no
                            + ".\nWe will e-mail you again when it ships.");
            case ORDER_SHIPPED -> new Email("Order " + no + " is on its way",
                    "Order " + no + " has shipped with " + o.getCarrier() + ".\nTracking number: "
                            + o.getTrackingNumber() + "\nYou can follow it under My orders.");
            case ORDER_DELIVERED -> new Email("Order " + no + " was delivered",
                    "Order " + no + " was delivered. Enjoy!\nSomething wrong? You can request a return within 30 days"
                            + " under My orders.");
            case ORDER_CANCELLED -> new Email("Order " + no + " was cancelled",
                    "Order " + no + " was cancelled." + (event.amount() == null ? ""
                            : "\nWe refunded " + money(event.amount()) + " to your card; it can take a few days to appear."));
            case RETURN_APPROVED -> new Email("Return approved for order " + no,
                    "Your return for order " + no + " was approved.\nRefund: " + money(event.amount())
                            + " to your card; it can take a few days to appear.");
            case RETURN_REJECTED -> new Email("Return for order " + no + " was not approved",
                    "Your return for order " + no + " was not approved. Questions? Reply to this e-mail.");
        };
    }

    static String money(BigDecimal amount) {
        return "$" + amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
    }

    private static String lines(List<OrderItem> items) {
        return items.stream()
                .map(i -> "  " + i.getQuantity() + " x " + i.getProductName() + "  " + money(i.getLineTotal()))
                .collect(Collectors.joining("\n")) + "\n";
    }

    /** "Ships to: ..." - no phone number in an e-mail; empty for orders placed before addresses existed. */
    private static String shipsTo(CustomerOrder o) {
        return o.getShippingAddress() == null ? "" : "Ships to: " + o.getShippingAddress().oneLine() + "\n";
    }

    private static String totals(CustomerOrder o) {
        StringBuilder s = new StringBuilder("Subtotal " + money(o.getSubtotal()) + "\n");
        if (o.getDiscount().signum() > 0) {
            s.append("Discount -").append(money(o.getDiscount())).append(" (").append(o.getCouponCode()).append(")\n");
        }
        return s.append("Shipping ").append(money(o.getShippingFee())).append("\nTax ").append(money(o.getTax()))
                .append("\nTotal ").append(money(o.getTotal())).append("\n").toString();
    }

    // ---------------------------------------------------------------- admin

    @Transactional(readOnly = true)
    public List<Notification> list(String status, Long orderId) {
        if (orderId != null) return notifications.findByOrderIdOrderByIdAsc(orderId);
        if (status != null && !status.isBlank()) return notifications.findTop100ByStatusOrderByIdDesc(status.trim().toUpperCase());
        return notifications.findTop100ByOrderByIdDesc();
    }

    /** 409 unless FAILED: a pending or sent e-mail is never sent twice by an admin click. */
    @Transactional
    public void retry(Long id) {
        if (notifications.requeue(id, LocalDateTime.now(clock)) == 0) {
            throw new ConflictException("Only a FAILED notification can be sent again");
        }
    }
}