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
1 change: 1 addition & 0 deletions lambdas/src/lib/types/notify-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export interface NotifyRecipient {
export enum NotifyEventCode {
OrderConfirmed = "ORDER_CONFIRMED",
OrderDispatched = "ORDER_DISPATCHED",
OrderReceived = "ORDER_RECEIVED",
}
17 changes: 17 additions & 0 deletions lambdas/src/order-status-lambda/notify-message-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,21 @@ describe("NotifyMessageBuilder", () => {

expect(mockGetPatient).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111");
});

it("should build received notify message with receivedDate in personalisation", async () => {
const result = await builder.buildOrderReceivedNotifyMessage({
patientId: "550e8400-e29b-41d4-a716-446655440111",
correlationId: "123e4567-e89b-12d3-a456-426614174000",
orderId: "550e8400-e29b-41d4-a716-446655440000",
receivedAt: "2026-08-06T10:00:00Z",
});

expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(result.eventCode).toBe(NotifyEventCode.OrderReceived);
expect(result.personalisation).toEqual({
receivedDate: "6 August 2026",
statusLink:
"[View kit order update and see more information](https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking)",
});
});
});
49 changes: 46 additions & 3 deletions lambdas/src/order-status-lambda/notify-message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ export interface BuildOrderDispatchedNotifyMessageInput {
dispatchedAt: string;
}

export interface BuildOrderReceivedNotifyMessageInput {
patientId: string;
correlationId: string;
orderId: string;
receivedAt: string;
}

const ORDER_TRACKING_LINK_TEXT = "View kit order update and see more information";

const formatDispatchedDate = (isoDateTime: string): string =>
const formatStatusDate = (isoDateTime: string): string =>
new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
Expand All @@ -35,6 +42,42 @@ export class NotifyMessageBuilder {
): Promise<NotifyMessage> {
const { patientId, correlationId, orderId, dispatchedAt } = input;

return this.buildOrderStatusNotifyMessage({
patientId,
correlationId,
orderId,
eventCode: NotifyEventCode.OrderDispatched,
personalisation: {
dispatchedDate: formatStatusDate(dispatchedAt),
},
});
}

async buildOrderReceivedNotifyMessage(
input: BuildOrderReceivedNotifyMessageInput,
): Promise<NotifyMessage> {
const { patientId, correlationId, orderId, receivedAt } = input;

return this.buildOrderStatusNotifyMessage({
patientId,
correlationId,
orderId,
eventCode: NotifyEventCode.OrderReceived,
personalisation: {
receivedDate: formatStatusDate(receivedAt),
},
});
}

private async buildOrderStatusNotifyMessage(input: {
patientId: string;
correlationId: string;
orderId: string;
eventCode: NotifyEventCode;
personalisation: Record<string, string>;
}): Promise<NotifyMessage> {
const { patientId, correlationId, orderId, eventCode, personalisation } = input;

const patient = await this.patientDbClient.get(patientId);
const recipient: NotifyRecipient = {
nhsNumber: patient.nhsNumber,
Expand All @@ -46,10 +89,10 @@ export class NotifyMessageBuilder {
return {
correlationId,
messageReference: uuidv4(),
eventCode: NotifyEventCode.OrderDispatched,
eventCode,
recipient,
personalisation: {
dispatchedDate: formatDispatchedDate(dispatchedAt),
...personalisation,
statusLink: `[${ORDER_TRACKING_LINK_TEXT}](${trackingUrl})`,
},
};
Expand Down
88 changes: 87 additions & 1 deletion lambdas/src/order-status-lambda/notify-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { OrderStatusNotifyService } from "./notify-service";
describe("OrderStatusNotifyService", () => {
const mockIsFirstStatusOccurrence = jest.fn<Promise<boolean>, [string, string]>();
const mockBuildOrderDispatchedNotifyMessage = jest.fn();
const mockBuildOrderReceivedNotifyMessage = jest.fn();
const mockSendMessage = jest.fn();
const mockInsertNotificationAuditEntry = jest.fn();

Expand All @@ -32,6 +33,16 @@ describe("OrderStatusNotifyService", () => {
},
personalisation: {},
});
mockBuildOrderReceivedNotifyMessage.mockResolvedValue({
messageReference: "123e4567-e89b-12d3-a456-426614174199",
eventCode: NotifyEventCode.OrderReceived,
correlationId: statusUpdate.correlationId,
recipient: {
nhsNumber: "1234567890",
dateOfBirth: "1990-01-02",
},
personalisation: {},
});
mockSendMessage.mockResolvedValue({ messageId: "message-id" });
mockInsertNotificationAuditEntry.mockResolvedValue(undefined);

Expand All @@ -47,6 +58,7 @@ describe("OrderStatusNotifyService", () => {
},
notifyMessageBuilder: {
buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage,
buildOrderReceivedNotifyMessage: mockBuildOrderReceivedNotifyMessage,
} as never,
notifyMessagesQueueUrl: "https://example.queue.local/notify",
});
Expand All @@ -59,12 +71,13 @@ describe("OrderStatusNotifyService", () => {
correlationId: statusUpdate.correlationId,
statusUpdate: {
...statusUpdate,
statusCode: OrderStatusCodes.RECEIVED,
statusCode: OrderStatusCodes.COMPLETE,
},
});

expect(mockIsFirstStatusOccurrence).not.toHaveBeenCalled();
expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled();
expect(mockBuildOrderReceivedNotifyMessage).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled();
});
Expand All @@ -84,6 +97,7 @@ describe("OrderStatusNotifyService", () => {
OrderStatusCodes.DISPATCHED,
);
expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled();
expect(mockBuildOrderReceivedNotifyMessage).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -114,6 +128,57 @@ describe("OrderStatusNotifyService", () => {
});
});

it("should not send a received notification when it is not the first occurrence", async () => {
mockIsFirstStatusOccurrence.mockResolvedValueOnce(false);

await service.handleOrderStatusUpdated({
orderId: statusUpdate.orderId,
patientId: "patient-123",
correlationId: statusUpdate.correlationId,
statusUpdate: {
...statusUpdate,
statusCode: OrderStatusCodes.RECEIVED,
},
});

expect(mockIsFirstStatusOccurrence).toHaveBeenCalledWith(
statusUpdate.orderId,
OrderStatusCodes.RECEIVED,
);
expect(mockBuildOrderReceivedNotifyMessage).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled();
});

it("should send and audit the first received notification", async () => {
await service.handleOrderStatusUpdated({
orderId: statusUpdate.orderId,
patientId: "patient-123",
correlationId: statusUpdate.correlationId,
statusUpdate: {
...statusUpdate,
statusCode: OrderStatusCodes.RECEIVED,
},
});

expect(mockBuildOrderReceivedNotifyMessage).toHaveBeenCalledWith({
patientId: "patient-123",
correlationId: statusUpdate.correlationId,
orderId: statusUpdate.orderId,
receivedAt: statusUpdate.createdAt,
});
expect(mockSendMessage).toHaveBeenCalledWith(
"https://example.queue.local/notify",
expect.any(String),
);
expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({
messageReference: "123e4567-e89b-12d3-a456-426614174199",
eventCode: NotifyEventCode.OrderReceived,
correlationId: statusUpdate.correlationId,
status: NotificationAuditStatus.QUEUED,
});
});

it("should swallow errors when building the notify message fails", async () => {
mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce(
new Error("Notify payload build failed"),
Expand Down Expand Up @@ -146,4 +211,25 @@ describe("OrderStatusNotifyService", () => {

expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled();
});

it("should swallow errors when building the received notify message fails", async () => {
mockBuildOrderReceivedNotifyMessage.mockRejectedValueOnce(
new Error("Notify payload build failed"),
);

await expect(
service.handleOrderStatusUpdated({
orderId: statusUpdate.orderId,
patientId: "patient-123",
correlationId: statusUpdate.correlationId,
statusUpdate: {
...statusUpdate,
statusCode: OrderStatusCodes.RECEIVED,
},
}),
).resolves.toBeUndefined();

expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled();
});
});
82 changes: 56 additions & 26 deletions lambdas/src/order-status-lambda/notify-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
NotificationAuditStatus,
} from "../lib/db/notification-audit-db-client";
import {
OrderStatusCode,
OrderStatusCodes,
OrderStatusService,
OrderStatusUpdateParams,
} from "../lib/db/order-status-db";
import { SQSClientInterface } from "../lib/sqs/sqs-client";
import type { NotifyMessage } from "../lib/types/notify-message";
import { NotifyMessageBuilder } from "./notify-message-builder";

const commons = new ConsoleCommons();
Expand All @@ -29,46 +31,73 @@ export interface HandleOrderStatusUpdatedInput {
statusUpdate: OrderStatusUpdateParams;
}

interface BuildNotifyMessageInput {
orderId: string;
patientId: string;
correlationId: string;
createdAt: string;
}

type NotifyMessageBuilderByStatus = Partial<
Record<OrderStatusCode, (input: BuildNotifyMessageInput) => Promise<NotifyMessage>>
>;

export class OrderStatusNotifyService {
constructor(private readonly dependencies: OrderStatusNotifyServiceDependencies) {}

async handleOrderStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise<void> {
const { statusUpdate } = input;
async handleOrderStatusUpdated(
handleOrderStatusUpdatedInput: HandleOrderStatusUpdatedInput,
): Promise<void> {
const { statusUpdate } = handleOrderStatusUpdatedInput;
const { notifyMessageBuilder } = this.dependencies;

switch (statusUpdate.statusCode) {
case OrderStatusCodes.DISPATCHED:
await this.handleDispatchedStatusUpdated(input);
return;
default:
return;
const buildNotifyMessageByStatus: NotifyMessageBuilderByStatus = {
[OrderStatusCodes.DISPATCHED]: ({ patientId, correlationId, orderId, createdAt }) =>
notifyMessageBuilder.buildOrderDispatchedNotifyMessage({
patientId,
correlationId,
orderId,
dispatchedAt: createdAt,
}),
[OrderStatusCodes.RECEIVED]: ({ patientId, correlationId, orderId, createdAt }) =>
notifyMessageBuilder.buildOrderReceivedNotifyMessage({
patientId,
correlationId,
orderId,
receivedAt: createdAt,
}),
};

const buildNotifyMessageFunc = buildNotifyMessageByStatus[statusUpdate.statusCode];

if (!buildNotifyMessageFunc) {
return;
}

await this.handleStatusUpdated(handleOrderStatusUpdatedInput, buildNotifyMessageFunc);
}

private async handleDispatchedStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise<void> {
private async handleStatusUpdated(
input: HandleOrderStatusUpdatedInput,
buildNotifyMessage: (input: BuildNotifyMessageInput) => Promise<NotifyMessage>,
): Promise<void> {
const { orderId, patientId, correlationId, statusUpdate } = input;
const {
orderStatusDb,
notificationAuditDbClient,
sqsClient,
notifyMessageBuilder,
notifyMessagesQueueUrl,
} = this.dependencies;

const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence(
orderId,
OrderStatusCodes.DISPATCHED,
);

if (!isFirstDispatched) {
const { statusCode } = statusUpdate;
const { orderStatusDb, notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl } =
this.dependencies;

const isFirstOccurrence = await orderStatusDb.isFirstStatusOccurrence(orderId, statusCode);

if (!isFirstOccurrence) {
return;
}

try {
const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({
const notifyMessage = await buildNotifyMessage({
patientId,
correlationId,
orderId,
dispatchedAt: statusUpdate.createdAt,
createdAt: statusUpdate.createdAt,
});

await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage));
Expand All @@ -80,9 +109,10 @@ export class OrderStatusNotifyService {
status: NotificationAuditStatus.QUEUED,
});
} catch (error) {
commons.logError(name, "Failed to send dispatched notification", {
commons.logError(name, "Failed to send status notification", {
correlationId,
orderId,
statusCode,
error,
});
}
Expand Down