Skip to content

Commit cce6dc5

Browse files
committed
feat: decouple fulfillment email side-effects via domain events
FulfillmentShippedEvent and FulfillmentDeliveredEvent records replace direct EmailService calls in FulfillmentService. FulfillmentEmailEventListener handles delivery after transaction commit (@TransactionalEventListener + @async), matching the existing OrderEmailEventListener pattern.
1 parent ee744bd commit cce6dc5

5 files changed

Lines changed: 157 additions & 23 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package io.k2dv.garden.fulfillment.event;
2+
3+
public record FulfillmentDeliveredEvent(
4+
String to,
5+
String orderRef,
6+
String frontendUrl
7+
) {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package io.k2dv.garden.fulfillment.event;
2+
3+
import io.k2dv.garden.auth.service.EmailService;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.event.TransactionPhase;
9+
import org.springframework.transaction.event.TransactionalEventListener;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
@Slf4j
14+
public class FulfillmentEmailEventListener {
15+
16+
private final EmailService emailService;
17+
18+
@Async("emailExecutor")
19+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
20+
public void onFulfillmentShipped(FulfillmentShippedEvent event) {
21+
try {
22+
emailService.sendShippingNotification(
23+
event.to(), event.orderRef(), event.trackingNumber(),
24+
event.trackingCompany(), event.trackingUrl(), event.frontendUrl());
25+
} catch (Exception e) {
26+
log.error("Failed to send shipping notification to {} for order {}: {}",
27+
event.to(), event.orderRef(), e.getMessage(), e);
28+
}
29+
}
30+
31+
@Async("emailExecutor")
32+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
33+
public void onFulfillmentDelivered(FulfillmentDeliveredEvent event) {
34+
try {
35+
emailService.sendOrderDelivered(event.to(), event.orderRef(), null, event.frontendUrl());
36+
} catch (Exception e) {
37+
log.error("Failed to send delivery notification to {} for order {}: {}",
38+
event.to(), event.orderRef(), e.getMessage(), e);
39+
}
40+
}
41+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.k2dv.garden.fulfillment.event;
2+
3+
public record FulfillmentShippedEvent(
4+
String to,
5+
String orderRef,
6+
String trackingNumber,
7+
String trackingCompany,
8+
String trackingUrl,
9+
String frontendUrl
10+
) {}

src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package io.k2dv.garden.fulfillment.service;
22

3+
import io.k2dv.garden.config.AppProperties;
34
import io.k2dv.garden.fulfillment.dto.CreateFulfillmentRequest;
45
import io.k2dv.garden.fulfillment.dto.FulfillmentItemResponse;
56
import io.k2dv.garden.fulfillment.dto.FulfillmentResponse;
67
import io.k2dv.garden.fulfillment.dto.UpdateFulfillmentRequest;
8+
import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent;
9+
import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent;
710
import io.k2dv.garden.fulfillment.model.Fulfillment;
811
import io.k2dv.garden.fulfillment.model.FulfillmentItem;
912
import io.k2dv.garden.fulfillment.model.FulfillmentStatus;
10-
import io.k2dv.garden.auth.service.EmailService;
11-
import io.k2dv.garden.config.AppProperties;
1213
import io.k2dv.garden.fulfillment.repository.FulfillmentItemRepository;
1314
import io.k2dv.garden.fulfillment.repository.FulfillmentRepository;
15+
import io.k2dv.garden.notification.model.NotificationType;
16+
import io.k2dv.garden.notification.service.NotificationPreferenceService;
17+
import io.k2dv.garden.order.model.Order;
1418
import io.k2dv.garden.order.model.OrderEventType;
1519
import io.k2dv.garden.order.model.OrderItem;
1620
import io.k2dv.garden.order.model.OrderStatus;
@@ -22,16 +26,13 @@
2226
import io.k2dv.garden.shared.exception.ValidationException;
2327
import io.k2dv.garden.user.model.User;
2428
import io.k2dv.garden.user.repository.UserRepository;
25-
import io.k2dv.garden.notification.model.NotificationType;
26-
import io.k2dv.garden.notification.service.NotificationPreferenceService;
2729
import io.k2dv.garden.webhook.model.WebhookEventType;
2830
import io.k2dv.garden.webhook.service.OutboundWebhookService;
2931
import lombok.RequiredArgsConstructor;
32+
import org.springframework.context.ApplicationEventPublisher;
3033
import org.springframework.stereotype.Service;
3134
import org.springframework.transaction.annotation.Transactional;
3235

33-
import io.k2dv.garden.order.model.Order;
34-
3536
import java.util.List;
3637
import java.util.Map;
3738
import java.util.UUID;
@@ -53,10 +54,10 @@ public class FulfillmentService {
5354
private final OrderItemRepository orderItemRepo;
5455
private final OrderEventService orderEventService;
5556
private final UserRepository userRepo;
56-
private final EmailService emailService;
5757
private final AppProperties appProperties;
5858
private final OutboundWebhookService outboundWebhookService;
5959
private final NotificationPreferenceService notificationPreferenceService;
60+
private final ApplicationEventPublisher eventPublisher;
6061

6162
/**
6263
* Records a new shipment against a paid order, validating that each requested line-item quantity
@@ -157,13 +158,13 @@ public FulfillmentResponse update(UUID orderId, UUID fulfillmentId, UpdateFulfil
157158
"Fulfillment updated", null, "admin", null);
158159

159160
if (transitioningToShipped) {
160-
sendShippingNotificationEmail(orderId, f);
161+
publishShippedEvent(orderId, f);
161162
outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_SHIPPED,
162163
Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString(),
163164
"trackingNumber", f.getTrackingNumber() != null ? f.getTrackingNumber() : ""));
164165
}
165166
if (transitioningToDelivered) {
166-
sendDeliveredEmail(orderId);
167+
publishDeliveredEvent(orderId);
167168
outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_DELIVERED,
168169
Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString()));
169170
}
@@ -201,34 +202,36 @@ public FulfillmentResponse getById(UUID orderId, UUID fulfillmentId) {
201202
return toResponse(f);
202203
}
203204

204-
private void sendDeliveredEmail(UUID orderId) {
205+
private void publishDeliveredEvent(UUID orderId) {
205206
orderRepo.findById(orderId).ifPresent(order -> {
206207
if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_DELIVERED)) return;
207-
String to = order.getGuestEmail() != null ? order.getGuestEmail()
208-
: (order.getUserId() != null
209-
? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null)
210-
: null);
208+
String to = resolveEmail(order);
211209
if (to == null) return;
212210
String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase();
213-
emailService.sendOrderDelivered(to, orderRef, null, appProperties.getFrontendUrl());
211+
eventPublisher.publishEvent(new FulfillmentDeliveredEvent(to, orderRef, appProperties.getFrontendUrl()));
214212
});
215213
}
216214

217-
private void sendShippingNotificationEmail(UUID orderId, Fulfillment f) {
215+
private void publishShippedEvent(UUID orderId, Fulfillment f) {
218216
orderRepo.findById(orderId).ifPresent(order -> {
219217
if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_SHIPPED)) return;
220-
String to = order.getGuestEmail() != null ? order.getGuestEmail()
221-
: (order.getUserId() != null
222-
? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null)
223-
: null);
218+
String to = resolveEmail(order);
224219
if (to == null) return;
225220
String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase();
226-
emailService.sendShippingNotification(to, orderRef,
227-
f.getTrackingNumber(), f.getTrackingCompany(), f.getTrackingUrl(),
228-
appProperties.getFrontendUrl());
221+
eventPublisher.publishEvent(new FulfillmentShippedEvent(
222+
to, orderRef, f.getTrackingNumber(), f.getTrackingCompany(),
223+
f.getTrackingUrl(), appProperties.getFrontendUrl()));
229224
});
230225
}
231226

227+
private String resolveEmail(Order order) {
228+
if (order.getGuestEmail() != null) return order.getGuestEmail();
229+
if (order.getUserId() != null) {
230+
return userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null);
231+
}
232+
return null;
233+
}
234+
232235
private void recalculateOrderStatus(UUID orderId) {
233236
List<OrderItem> orderItems = orderItemRepo.findByOrderId(orderId);
234237
if (orderItems.isEmpty()) return;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.k2dv.garden.fulfillment.event;
2+
3+
import io.k2dv.garden.auth.service.EmailService;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.api.extension.ExtendWith;
6+
import org.mockito.InjectMocks;
7+
import org.mockito.Mock;
8+
import org.mockito.junit.jupiter.MockitoExtension;
9+
10+
import static org.assertj.core.api.Assertions.assertThatCode;
11+
import static org.mockito.Mockito.doThrow;
12+
import static org.mockito.Mockito.verify;
13+
14+
@ExtendWith(MockitoExtension.class)
15+
class FulfillmentEmailEventListenerTest {
16+
17+
@Mock
18+
EmailService emailService;
19+
20+
@InjectMocks
21+
FulfillmentEmailEventListener listener;
22+
23+
@Test
24+
void onFulfillmentShipped_delegatesToEmailService() {
25+
var event = new FulfillmentShippedEvent(
26+
"buyer@example.com", "#ABC123", "1Z9999", "UPS",
27+
"https://ups.com/track?n=1Z9999", "http://localhost:3000");
28+
29+
listener.onFulfillmentShipped(event);
30+
31+
verify(emailService).sendShippingNotification(
32+
"buyer@example.com", "#ABC123", "1Z9999", "UPS",
33+
"https://ups.com/track?n=1Z9999", "http://localhost:3000");
34+
}
35+
36+
@Test
37+
void onFulfillmentDelivered_delegatesToEmailService() {
38+
var event = new FulfillmentDeliveredEvent(
39+
"buyer@example.com", "#ABC123", "http://localhost:3000");
40+
41+
listener.onFulfillmentDelivered(event);
42+
43+
verify(emailService).sendOrderDelivered(
44+
"buyer@example.com", "#ABC123", null, "http://localhost:3000");
45+
}
46+
47+
@Test
48+
void onFulfillmentShipped_emailServiceThrows_doesNotPropagate() {
49+
var event = new FulfillmentShippedEvent(
50+
"bad@example.com", "#XYZ", null, null, null, "http://localhost:3000");
51+
doThrow(new RuntimeException("SMTP down"))
52+
.when(emailService).sendShippingNotification(
53+
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
54+
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
55+
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
56+
57+
assertThatCode(() -> listener.onFulfillmentShipped(event))
58+
.doesNotThrowAnyException();
59+
}
60+
61+
@Test
62+
void onFulfillmentDelivered_emailServiceThrows_doesNotPropagate() {
63+
var event = new FulfillmentDeliveredEvent(
64+
"bad@example.com", "#XYZ", "http://localhost:3000");
65+
doThrow(new RuntimeException("SMTP down"))
66+
.when(emailService).sendOrderDelivered(
67+
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
68+
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
69+
70+
assertThatCode(() -> listener.onFulfillmentDelivered(event))
71+
.doesNotThrowAnyException();
72+
}
73+
}

0 commit comments

Comments
 (0)