Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.k2dv.garden.fulfillment.event;

public record FulfillmentDeliveredEvent(
String to,
String orderRef,
String frontendUrl
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.k2dv.garden.fulfillment.event;

import io.k2dv.garden.auth.service.EmailService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
@Slf4j
public class FulfillmentEmailEventListener {

private final EmailService emailService;

@Async("emailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onFulfillmentShipped(FulfillmentShippedEvent event) {
try {
emailService.sendShippingNotification(
event.to(), event.orderRef(), event.trackingNumber(),
event.trackingCompany(), event.trackingUrl(), event.frontendUrl());
} catch (Exception e) {
log.error("Failed to send shipping notification to {} for order {}: {}",
event.to(), event.orderRef(), e.getMessage(), e);
}
}

@Async("emailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onFulfillmentDelivered(FulfillmentDeliveredEvent event) {
try {
emailService.sendOrderDelivered(event.to(), event.orderRef(), null, event.frontendUrl());
} catch (Exception e) {
log.error("Failed to send delivery notification to {} for order {}: {}",
event.to(), event.orderRef(), e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.k2dv.garden.fulfillment.event;

public record FulfillmentShippedEvent(
String to,
String orderRef,
String trackingNumber,
String trackingCompany,
String trackingUrl,
String frontendUrl
) {}
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package io.k2dv.garden.fulfillment.service;

import io.k2dv.garden.config.AppProperties;
import io.k2dv.garden.fulfillment.dto.CreateFulfillmentRequest;
import io.k2dv.garden.fulfillment.dto.FulfillmentItemResponse;
import io.k2dv.garden.fulfillment.dto.FulfillmentResponse;
import io.k2dv.garden.fulfillment.dto.UpdateFulfillmentRequest;
import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent;
import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent;
import io.k2dv.garden.fulfillment.model.Fulfillment;
import io.k2dv.garden.fulfillment.model.FulfillmentItem;
import io.k2dv.garden.fulfillment.model.FulfillmentStatus;
import io.k2dv.garden.auth.service.EmailService;
import io.k2dv.garden.config.AppProperties;
import io.k2dv.garden.fulfillment.repository.FulfillmentItemRepository;
import io.k2dv.garden.fulfillment.repository.FulfillmentRepository;
import io.k2dv.garden.notification.model.NotificationType;
import io.k2dv.garden.notification.service.NotificationPreferenceService;
import io.k2dv.garden.order.model.Order;
import io.k2dv.garden.order.model.OrderEventType;
import io.k2dv.garden.order.model.OrderItem;
import io.k2dv.garden.order.model.OrderStatus;
Expand All @@ -22,16 +26,13 @@
import io.k2dv.garden.shared.exception.ValidationException;
import io.k2dv.garden.user.model.User;
import io.k2dv.garden.user.repository.UserRepository;
import io.k2dv.garden.notification.model.NotificationType;
import io.k2dv.garden.notification.service.NotificationPreferenceService;
import io.k2dv.garden.webhook.model.WebhookEventType;
import io.k2dv.garden.webhook.service.OutboundWebhookService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import io.k2dv.garden.order.model.Order;

import java.util.List;
import java.util.Map;
import java.util.UUID;
Expand All @@ -53,10 +54,10 @@ public class FulfillmentService {
private final OrderItemRepository orderItemRepo;
private final OrderEventService orderEventService;
private final UserRepository userRepo;
private final EmailService emailService;
private final AppProperties appProperties;
private final OutboundWebhookService outboundWebhookService;
private final NotificationPreferenceService notificationPreferenceService;
private final ApplicationEventPublisher eventPublisher;

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

if (transitioningToShipped) {
sendShippingNotificationEmail(orderId, f);
publishShippedEvent(orderId, f);
outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_SHIPPED,
Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString(),
"trackingNumber", f.getTrackingNumber() != null ? f.getTrackingNumber() : ""));
}
if (transitioningToDelivered) {
sendDeliveredEmail(orderId);
publishDeliveredEvent(orderId);
outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_DELIVERED,
Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString()));
}
Expand Down Expand Up @@ -201,34 +202,36 @@ public FulfillmentResponse getById(UUID orderId, UUID fulfillmentId) {
return toResponse(f);
}

private void sendDeliveredEmail(UUID orderId) {
private void publishDeliveredEvent(UUID orderId) {
orderRepo.findById(orderId).ifPresent(order -> {
if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_DELIVERED)) return;
String to = order.getGuestEmail() != null ? order.getGuestEmail()
: (order.getUserId() != null
? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null)
: null);
String to = resolveEmail(order);
if (to == null) return;
String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase();
emailService.sendOrderDelivered(to, orderRef, null, appProperties.getFrontendUrl());
eventPublisher.publishEvent(new FulfillmentDeliveredEvent(to, orderRef, appProperties.getFrontendUrl()));
});
}

private void sendShippingNotificationEmail(UUID orderId, Fulfillment f) {
private void publishShippedEvent(UUID orderId, Fulfillment f) {
orderRepo.findById(orderId).ifPresent(order -> {
if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_SHIPPED)) return;
String to = order.getGuestEmail() != null ? order.getGuestEmail()
: (order.getUserId() != null
? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null)
: null);
String to = resolveEmail(order);
if (to == null) return;
String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase();
emailService.sendShippingNotification(to, orderRef,
f.getTrackingNumber(), f.getTrackingCompany(), f.getTrackingUrl(),
appProperties.getFrontendUrl());
eventPublisher.publishEvent(new FulfillmentShippedEvent(
to, orderRef, f.getTrackingNumber(), f.getTrackingCompany(),
f.getTrackingUrl(), appProperties.getFrontendUrl()));
});
}

private String resolveEmail(Order order) {
if (order.getGuestEmail() != null) return order.getGuestEmail();
if (order.getUserId() != null) {
return userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null);
}
return null;
}

private void recalculateOrderStatus(UUID orderId) {
List<OrderItem> orderItems = orderItemRepo.findByOrderId(orderId);
if (orderItems.isEmpty()) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.k2dv.garden.fulfillment.event;

import io.k2dv.garden.auth.service.EmailService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class FulfillmentEmailEventListenerTest {

@Mock
EmailService emailService;

@InjectMocks
FulfillmentEmailEventListener listener;

@Test
void onFulfillmentShipped_delegatesToEmailService() {
var event = new FulfillmentShippedEvent(
"buyer@example.com", "#ABC123", "1Z9999", "UPS",
"https://ups.com/track?n=1Z9999", "http://localhost:3000");

listener.onFulfillmentShipped(event);

verify(emailService).sendShippingNotification(
"buyer@example.com", "#ABC123", "1Z9999", "UPS",
"https://ups.com/track?n=1Z9999", "http://localhost:3000");
}

@Test
void onFulfillmentDelivered_delegatesToEmailService() {
var event = new FulfillmentDeliveredEvent(
"buyer@example.com", "#ABC123", "http://localhost:3000");

listener.onFulfillmentDelivered(event);

verify(emailService).sendOrderDelivered(
"buyer@example.com", "#ABC123", null, "http://localhost:3000");
}

@Test
void onFulfillmentShipped_emailServiceThrows_doesNotPropagate() {
var event = new FulfillmentShippedEvent(
"bad@example.com", "#XYZ", null, null, null, "http://localhost:3000");
doThrow(new RuntimeException("SMTP down"))
.when(emailService).sendShippingNotification(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());

assertThatCode(() -> listener.onFulfillmentShipped(event))
.doesNotThrowAnyException();
}

@Test
void onFulfillmentDelivered_emailServiceThrows_doesNotPropagate() {
var event = new FulfillmentDeliveredEvent(
"bad@example.com", "#XYZ", "http://localhost:3000");
doThrow(new RuntimeException("SMTP down"))
.when(emailService).sendOrderDelivered(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());

assertThatCode(() -> listener.onFulfillmentDelivered(event))
.doesNotThrowAnyException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import io.k2dv.garden.fulfillment.dto.FulfillmentItemRequest;
import io.k2dv.garden.fulfillment.dto.FulfillmentResponse;
import io.k2dv.garden.fulfillment.dto.UpdateFulfillmentRequest;
import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent;
import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent;
import io.k2dv.garden.fulfillment.model.FulfillmentStatus;
import io.k2dv.garden.inventory.model.InventoryLevel;
import io.k2dv.garden.inventory.model.Location;
Expand Down Expand Up @@ -36,6 +38,8 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;

import java.math.BigDecimal;
import java.util.List;
Expand All @@ -44,12 +48,8 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@RecordApplicationEvents
class FulfillmentServiceIT extends AbstractIntegrationTest {

@Autowired FulfillmentService fulfillmentService;
Expand All @@ -64,6 +64,7 @@ class FulfillmentServiceIT extends AbstractIntegrationTest {
@Autowired InventoryItemRepository inventoryItemRepo;
@Autowired InventoryLevelRepository levelRepo;
@MockitoBean EmailService emailService;
@Autowired ApplicationEvents applicationEvents;

private static final AtomicInteger counter = new AtomicInteger(0);

Expand Down Expand Up @@ -248,13 +249,15 @@ void update_pendingToShipped_sendsShippingNotificationToCustomer() {
new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null));

String orderRef = "#" + order.getId().toString().substring(0, 8).toUpperCase();
verify(emailService).sendShippingNotification(
eq(adminUser.getEmail()),
eq(orderRef),
eq("T-SHIP"),
eq("UPS"),
eq("https://track.example/T-SHIP"),
eq("http://localhost:3000"));
assertThat(applicationEvents.stream(FulfillmentShippedEvent.class))
.singleElement()
.satisfies(event -> {
assertThat(event.to()).isEqualTo(adminUser.getEmail());
assertThat(event.orderRef()).isEqualTo(orderRef);
assertThat(event.trackingNumber()).isEqualTo("T-SHIP");
assertThat(event.trackingCompany()).isEqualTo("UPS");
assertThat(event.trackingUrl()).isEqualTo("https://track.example/T-SHIP");
});
}

@Test
Expand All @@ -271,13 +274,7 @@ void update_reapplyingShippedStatus_doesNotResendShippingNotification() {
fulfillmentService.update(order.getId(), f.id(),
new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null));

verify(emailService, times(1)).sendShippingNotification(
eq(adminUser.getEmail()),
eq("#" + order.getId().toString().substring(0, 8).toUpperCase()),
eq("T-ONCE"),
eq("UPS"),
isNull(),
eq("http://localhost:3000"));
assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isEqualTo(1);
}

@Test
Expand Down Expand Up @@ -310,11 +307,13 @@ void update_shippedToDelivered_sendsDeliveredNotificationToCustomer() {
fulfillmentService.update(order.getId(), f.id(),
new UpdateFulfillmentRequest(FulfillmentStatus.DELIVERED, null, null, null, null));

verify(emailService).sendOrderDelivered(
eq(adminUser.getEmail()),
eq("#" + order.getId().toString().substring(0, 8).toUpperCase()),
isNull(),
eq("http://localhost:3000"));
String orderRef = "#" + order.getId().toString().substring(0, 8).toUpperCase();
assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class))
.singleElement()
.satisfies(event -> {
assertThat(event.to()).isEqualTo(adminUser.getEmail());
assertThat(event.orderRef()).isEqualTo(orderRef);
});
}

@Test
Expand All @@ -329,18 +328,8 @@ void update_cancelledFulfillment_doesNotSendShippingOrDeliveredNotification() {
fulfillmentService.update(order.getId(), f.id(),
new UpdateFulfillmentRequest(FulfillmentStatus.CANCELLED, null, null, null, null));

verify(emailService, never()).sendShippingNotification(
eq(adminUser.getEmail()),
eq("#" + order.getId().toString().substring(0, 8).toUpperCase()),
eq("T-CANCEL"),
isNull(),
isNull(),
eq("http://localhost:3000"));
verify(emailService, never()).sendOrderDelivered(
eq(adminUser.getEmail()),
eq("#" + order.getId().toString().substring(0, 8).toUpperCase()),
isNull(),
eq("http://localhost:3000"));
assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isZero();
assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class).count()).isZero();
}

@Test
Expand Down
Loading
Loading