From 1b25529104dee357f47febbb6b86e080927982bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 13 Apr 2026 15:34:43 +0200 Subject: [PATCH 1/7] introduced new lambda for scheduler event adn refactored code for notify service --- .../lib/db/notification-audit-db-client.ts | 3 +- .../lib/db/order-status-reminder-db-client.ts | 31 ++++ .../base-notify-message-builder.ts | 61 +++++++ ...ispatched-reminder-message-builder.test.ts | 55 ++++++ .../dispatched-reminder-message-builder.ts | 45 +++++ .../order-dispatched-message-builder.test.ts | 53 ++++++ .../order-dispatched-message-builder.ts | 42 +++++ .../order-received-message-builder.test.ts | 49 ++++++ .../order-received-message-builder.ts | 42 +++++ ...r-result-available-message-builder.test.ts | 44 +++++ .../order-result-available-message-builder.ts | 38 ++++ .../lib/notify/notify-message-builder.test.ts | 162 ------------------ .../src/lib/notify/notify-message-builder.ts | 139 --------------- lambdas/src/lib/notify/notify-service.ts | 109 ------------ .../notify/services/base-notify-service.ts | 57 ++++++ .../order-status-notify-service.test.ts} | 59 ++++--- .../services/order-status-notify-service.ts | 54 ++++++ .../services/reminder-notify-service.test.ts | 138 +++++++++++++++ .../services/reminder-notify-service.ts | 61 +++++++ lambdas/src/lib/types/notify-message.ts | 4 +- lambdas/src/order-result-lambda/index.test.ts | 10 +- lambdas/src/order-result-lambda/index.ts | 2 +- lambdas/src/order-result-lambda/init.test.ts | 25 +-- lambdas/src/order-result-lambda/init.ts | 13 +- lambdas/src/order-status-lambda/index.test.ts | 14 +- lambdas/src/order-status-lambda/index.ts | 2 +- lambdas/src/order-status-lambda/init.test.ts | 29 +--- lambdas/src/order-status-lambda/init.ts | 13 +- .../src/reminder-dispatch-lambda/config.ts | 96 +++++++++++ .../reminder-dispatch-lambda/index.test.ts | 127 ++++++++++++++ lambdas/src/reminder-dispatch-lambda/index.ts | 90 ++++++++++ lambdas/src/reminder-dispatch-lambda/init.ts | 50 ++++++ 32 files changed, 1211 insertions(+), 506 deletions(-) create mode 100644 lambdas/src/lib/db/order-status-reminder-db-client.ts create mode 100644 lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts create mode 100644 lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts create mode 100644 lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-received-message-builder.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts delete mode 100644 lambdas/src/lib/notify/notify-message-builder.test.ts delete mode 100644 lambdas/src/lib/notify/notify-message-builder.ts delete mode 100644 lambdas/src/lib/notify/notify-service.ts create mode 100644 lambdas/src/lib/notify/services/base-notify-service.ts rename lambdas/src/lib/notify/{notify-service.test.ts => services/order-status-notify-service.test.ts} (77%) create mode 100644 lambdas/src/lib/notify/services/order-status-notify-service.ts create mode 100644 lambdas/src/lib/notify/services/reminder-notify-service.test.ts create mode 100644 lambdas/src/lib/notify/services/reminder-notify-service.ts create mode 100644 lambdas/src/reminder-dispatch-lambda/config.ts create mode 100644 lambdas/src/reminder-dispatch-lambda/index.test.ts create mode 100644 lambdas/src/reminder-dispatch-lambda/index.ts create mode 100644 lambdas/src/reminder-dispatch-lambda/init.ts diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts index 9a4b904d..28c186b0 100644 --- a/lambdas/src/lib/db/notification-audit-db-client.ts +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -1,4 +1,3 @@ -import { type NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; export enum NotificationAuditStatus { @@ -9,7 +8,7 @@ export enum NotificationAuditStatus { export interface NotificationAuditEntryParams { messageReference: string; - eventCode: NotifyEventCode; + eventCode: string; correlationId: string; status: NotificationAuditStatus; notifyMessageId?: string | null; diff --git a/lambdas/src/lib/db/order-status-reminder-db-client.ts b/lambdas/src/lib/db/order-status-reminder-db-client.ts new file mode 100644 index 00000000..8cb33033 --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.ts @@ -0,0 +1,31 @@ +import { type DBClient } from "./db-client"; +import { type OrderStatusCode, OrderStatusCodes } from "./order-status-db"; + +export interface OrderStatusReminderRecord { + reminderId: string; + orderUid: string; + statusCode: OrderStatusCode; + reminderNumber: number; +} + +export class OrderStatusReminderDbClient { + constructor(private readonly dbClient: DBClient) {} + + async getPendingReminders(): Promise { + // Temporary mock data. Replace with a DB query against order_status_reminder. + return [ + { + reminderId: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", + orderUid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", + statusCode: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + }, + { + reminderId: "2ddb4bcb-ee7f-4f89-a126-30e56fc23338", + orderUid: "7f97f8a4-75f3-47dc-8faf-f7f9ca6ec1ac", + statusCode: OrderStatusCodes.DISPATCHED, + reminderNumber: 2, + }, + ]; + } +} diff --git a/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts new file mode 100644 index 00000000..281fb13a --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts @@ -0,0 +1,61 @@ +import { v4 as uuidv4 } from "uuid"; + +import type { OrderDbClient } from "../../db/order-db-client"; +import type { PatientDbClient } from "../../db/patient-db-client"; +import type { NotifyMessage, NotifyRecipient } from "../../types/notify-message"; + +export interface NotifyMessageBuilderDependencies { + patientDbClient: PatientDbClient; + orderDbClient: OrderDbClient; + homeTestBaseUrl: string; +} + +export abstract class BaseNotifyMessageBuilder { + private readonly normalizedHomeTestBaseUrl: string; + + constructor(protected readonly deps: NotifyMessageBuilderDependencies) { + this.normalizedHomeTestBaseUrl = deps.homeTestBaseUrl.replaceAll(/\/$/g, ""); + } + + protected async getRecipient(patientId: string): Promise { + const patient = await this.deps.patientDbClient.get(patientId); + return { nhsNumber: patient.nhsNumber, dateOfBirth: patient.birthDate }; + } + + protected async getReferenceNumber(orderId: string): Promise { + return this.deps.orderDbClient.getOrderReferenceNumber(orderId); + } + + protected buildTrackingUrl(orderId: string): string { + return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; + } + + protected buildResultsUrl(orderId: string): string { + return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/results`; + } + + protected buildMessage(params: { + correlationId: string; + eventCode: string; + recipient: NotifyRecipient; + personalisation: Record; + messageReference?: string; + }): NotifyMessage { + return { + correlationId: params.correlationId, + messageReference: params.messageReference ?? uuidv4(), + eventCode: params.eventCode, + recipient: params.recipient, + personalisation: params.personalisation, + }; + } + + protected formatStatusDate(isoDateTime: string): string { + return new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(isoDateTime)); + } +} diff --git a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts new file mode 100644 index 00000000..b5d0f454 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts @@ -0,0 +1,55 @@ +import type { OrderDbClient } from "../../db/order-db-client"; +import type { OrderStatusService } from "../../db/order-status-db"; +import { OrderStatusCodes } from "../../db/order-status-db"; +import type { Patient, PatientDbClient } from "../../db/patient-db-client"; +import { NotifyEventCode } from "../../types/notify-message"; +import { DispatchedReminderMessageBuilder } from "./dispatched-reminder-message-builder"; + +describe("DispatchedReminderMessageBuilder", () => { + const mockGetPatient = jest.fn, [string]>(); + const mockGetOrderReferenceNumber = jest.fn, [string]>(); + const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); + + const deps = { + patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, + orderDbClient: { + getOrderReferenceNumber: mockGetOrderReferenceNumber, + } as Pick as OrderDbClient, + homeTestBaseUrl: "https://hometest.example.nhs.uk/", + }; + + const orderStatusService = { + getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, + } as Pick as OrderStatusService; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); + mockGetOrderReferenceNumber.mockResolvedValue("100001"); + mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); + }); + + it("builds dispatched reminder message using reminder id as message reference", async () => { + const builder = new DispatchedReminderMessageBuilder(deps, orderStatusService); + + const result = await builder.build({ + reminderId: "rem-1", + patientId: "patient-3", + orderId: "order-4", + correlationId: "corr-4", + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + + expect(result.eventCode).toBe(NotifyEventCode.DispatchedInitialReminder); + expect(result.messageReference).toBe("rem-1"); + expect(result.personalisation).toEqual({ + dispatchedDate: "6 August 2026", + orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-4/tracking", + referenceNumber: "100001", + }); + expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( + "order-4", + OrderStatusCodes.DISPATCHED, + ); + }); +}); diff --git a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts b/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts new file mode 100644 index 00000000..9577f38f --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts @@ -0,0 +1,45 @@ +import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; +import { type NotifyMessage } from "../../types/notify-message"; +import { + BaseNotifyMessageBuilder, + type NotifyMessageBuilderDependencies, +} from "./base-notify-message-builder"; + +export interface DispatchedReminderMessageBuilderInput { + reminderId: string; + patientId: string; + orderId: string; + correlationId: string; + eventCode: string; +} + +export class DispatchedReminderMessageBuilder extends BaseNotifyMessageBuilder { + constructor( + deps: NotifyMessageBuilderDependencies, + private readonly orderStatusService: OrderStatusService, + ) { + super(deps); + } + + async build(input: DispatchedReminderMessageBuilderInput): Promise { + const { reminderId, patientId, orderId, correlationId, eventCode } = input; + + const [recipient, referenceNumber, dispatchedAt] = await Promise.all([ + this.getRecipient(patientId), + this.getReferenceNumber(orderId), + this.orderStatusService.getOrderStatusCreatedAt(orderId, OrderStatusCodes.DISPATCHED), + ]); + + return this.buildMessage({ + correlationId, + eventCode, + recipient, + messageReference: reminderId, + personalisation: { + dispatchedDate: this.formatStatusDate(dispatchedAt), + orderLinkUrl: this.buildTrackingUrl(orderId), + referenceNumber, + }, + }); + } +} diff --git a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts new file mode 100644 index 00000000..00e8d8d4 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts @@ -0,0 +1,53 @@ +import type { OrderDbClient } from "../../db/order-db-client"; +import type { OrderStatusService } from "../../db/order-status-db"; +import { OrderStatusCodes } from "../../db/order-status-db"; +import type { Patient, PatientDbClient } from "../../db/patient-db-client"; +import { NotifyEventCode } from "../../types/notify-message"; +import { OrderDispatchedMessageBuilder } from "./order-dispatched-message-builder"; + +describe("OrderDispatchedMessageBuilder", () => { + const mockGetPatient = jest.fn, [string]>(); + const mockGetOrderReferenceNumber = jest.fn, [string]>(); + const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); + + const deps = { + patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, + orderDbClient: { + getOrderReferenceNumber: mockGetOrderReferenceNumber, + } as Pick as OrderDbClient, + homeTestBaseUrl: "https://hometest.example.nhs.uk/", + }; + + const orderStatusService = { + getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, + } as Pick as OrderStatusService; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); + mockGetOrderReferenceNumber.mockResolvedValue("100001"); + mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); + }); + + it("builds dispatched notify message", async () => { + const builder = new OrderDispatchedMessageBuilder(deps, orderStatusService); + + const result = await builder.build({ + patientId: "patient-1", + orderId: "order-1", + correlationId: "corr-1", + }); + + expect(result.eventCode).toBe(NotifyEventCode.OrderDispatched); + expect(result.correlationId).toBe("corr-1"); + expect(result.personalisation).toEqual({ + dispatchedDate: "6 August 2026", + orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-1/tracking", + referenceNumber: "100001", + }); + expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( + "order-1", + OrderStatusCodes.DISPATCHED, + ); + }); +}); diff --git a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts new file mode 100644 index 00000000..6447c5ae --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts @@ -0,0 +1,42 @@ +import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; +import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { + BaseNotifyMessageBuilder, + type NotifyMessageBuilderDependencies, +} from "./base-notify-message-builder"; + +export interface OrderDispatchedMessageBuilderInput { + patientId: string; + orderId: string; + correlationId: string; +} + +export class OrderDispatchedMessageBuilder extends BaseNotifyMessageBuilder { + constructor( + deps: NotifyMessageBuilderDependencies, + private readonly orderStatusService: OrderStatusService, + ) { + super(deps); + } + + async build(input: OrderDispatchedMessageBuilderInput): Promise { + const { patientId, orderId, correlationId } = input; + + const [recipient, referenceNumber, dispatchedAt] = await Promise.all([ + this.getRecipient(patientId), + this.getReferenceNumber(orderId), + this.orderStatusService.getOrderStatusCreatedAt(orderId, OrderStatusCodes.DISPATCHED), + ]); + + return this.buildMessage({ + correlationId, + eventCode: NotifyEventCode.OrderDispatched, + recipient, + personalisation: { + dispatchedDate: this.formatStatusDate(dispatchedAt), + orderLinkUrl: this.buildTrackingUrl(orderId), + referenceNumber, + }, + }); + } +} diff --git a/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts new file mode 100644 index 00000000..6deebc1e --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts @@ -0,0 +1,49 @@ +import type { OrderDbClient } from "../../db/order-db-client"; +import type { OrderStatusService } from "../../db/order-status-db"; +import { OrderStatusCodes } from "../../db/order-status-db"; +import type { Patient, PatientDbClient } from "../../db/patient-db-client"; +import { NotifyEventCode } from "../../types/notify-message"; +import { OrderReceivedMessageBuilder } from "./order-received-message-builder"; + +describe("OrderReceivedMessageBuilder", () => { + const mockGetPatient = jest.fn, [string]>(); + const mockGetOrderReferenceNumber = jest.fn, [string]>(); + const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); + + const deps = { + patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, + orderDbClient: { + getOrderReferenceNumber: mockGetOrderReferenceNumber, + } as Pick as OrderDbClient, + homeTestBaseUrl: "https://hometest.example.nhs.uk/", + }; + + const orderStatusService = { + getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, + } as Pick as OrderStatusService; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); + mockGetOrderReferenceNumber.mockResolvedValue("100001"); + mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); + }); + + it("builds received notify message", async () => { + const builder = new OrderReceivedMessageBuilder(deps, orderStatusService); + + const result = await builder.build({ + patientId: "patient-1", + orderId: "order-2", + correlationId: "corr-2", + }); + + expect(result.eventCode).toBe(NotifyEventCode.OrderReceived); + expect(result.personalisation).toEqual({ + receivedDate: "6 August 2026", + orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-2/tracking", + referenceNumber: "100001", + }); + expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith("order-2", OrderStatusCodes.RECEIVED); + }); +}); diff --git a/lambdas/src/lib/notify/message-builders/order-received-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-received-message-builder.ts new file mode 100644 index 00000000..f22ea6a0 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-received-message-builder.ts @@ -0,0 +1,42 @@ +import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; +import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { + BaseNotifyMessageBuilder, + type NotifyMessageBuilderDependencies, +} from "./base-notify-message-builder"; + +export interface OrderReceivedMessageBuilderInput { + patientId: string; + orderId: string; + correlationId: string; +} + +export class OrderReceivedMessageBuilder extends BaseNotifyMessageBuilder { + constructor( + deps: NotifyMessageBuilderDependencies, + private readonly orderStatusService: OrderStatusService, + ) { + super(deps); + } + + async build(input: OrderReceivedMessageBuilderInput): Promise { + const { patientId, orderId, correlationId } = input; + + const [recipient, referenceNumber, receivedAt] = await Promise.all([ + this.getRecipient(patientId), + this.getReferenceNumber(orderId), + this.orderStatusService.getOrderStatusCreatedAt(orderId, OrderStatusCodes.RECEIVED), + ]); + + return this.buildMessage({ + correlationId, + eventCode: NotifyEventCode.OrderReceived, + recipient, + personalisation: { + receivedDate: this.formatStatusDate(receivedAt), + orderLinkUrl: this.buildTrackingUrl(orderId), + referenceNumber, + }, + }); + } +} diff --git a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts new file mode 100644 index 00000000..96c7d7e6 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts @@ -0,0 +1,44 @@ +import type { OrderDbClient } from "../../db/order-db-client"; +import type { Patient, PatientDbClient } from "../../db/patient-db-client"; +import { NotifyEventCode } from "../../types/notify-message"; +import { OrderResultAvailableMessageBuilder } from "./order-result-available-message-builder"; + +describe("OrderResultAvailableMessageBuilder", () => { + const mockGetPatient = jest.fn, [string]>(); + const mockGetOrderReferenceNumber = jest.fn, [string]>(); + const mockGetOrderCreatedAt = jest.fn, [string]>(); + + const deps = { + patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, + orderDbClient: { + getOrderReferenceNumber: mockGetOrderReferenceNumber, + getOrderCreatedAt: mockGetOrderCreatedAt, + } as Pick as OrderDbClient, + homeTestBaseUrl: "https://hometest.example.nhs.uk/", + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); + mockGetOrderReferenceNumber.mockResolvedValue("100001"); + mockGetOrderCreatedAt.mockResolvedValue("2026-08-05T10:00:00Z"); + }); + + it("builds result available notify message", async () => { + const builder = new OrderResultAvailableMessageBuilder(deps); + + const result = await builder.build({ + patientId: "patient-2", + orderId: "order-3", + correlationId: "corr-3", + }); + + expect(result.eventCode).toBe(NotifyEventCode.ResultReady); + expect(result.personalisation).toEqual({ + orderedDate: "5 August 2026", + resultLinkUrl: "https://hometest.example.nhs.uk/orders/order-3/results", + referenceNumber: "100001", + }); + expect(mockGetOrderCreatedAt).toHaveBeenCalledWith("order-3"); + }); +}); diff --git a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts new file mode 100644 index 00000000..ebd3b2b1 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts @@ -0,0 +1,38 @@ +import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { + BaseNotifyMessageBuilder, + type NotifyMessageBuilderDependencies, +} from "./base-notify-message-builder"; + +export interface OrderResultAvailableMessageBuilderInput { + patientId: string; + orderId: string; + correlationId: string; +} + +export class OrderResultAvailableMessageBuilder extends BaseNotifyMessageBuilder { + constructor(deps: NotifyMessageBuilderDependencies) { + super(deps); + } + + async build(input: OrderResultAvailableMessageBuilderInput): Promise { + const { patientId, orderId, correlationId } = input; + + const [recipient, referenceNumber, orderCreatedAt] = await Promise.all([ + this.getRecipient(patientId), + this.getReferenceNumber(orderId), + this.deps.orderDbClient.getOrderCreatedAt(orderId), + ]); + + return this.buildMessage({ + correlationId, + eventCode: NotifyEventCode.ResultReady, + recipient, + personalisation: { + orderedDate: this.formatStatusDate(orderCreatedAt), + resultLinkUrl: this.buildResultsUrl(orderId), + referenceNumber, + }, + }); + } +} diff --git a/lambdas/src/lib/notify/notify-message-builder.test.ts b/lambdas/src/lib/notify/notify-message-builder.test.ts deleted file mode 100644 index 2432fc67..00000000 --- a/lambdas/src/lib/notify/notify-message-builder.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { OrderDbClient } from "../db/order-db-client"; -import type { OrderStatusService } from "../db/order-status-db"; -import { OrderStatusCodes } from "../db/order-status-db"; -import type { Patient, PatientDbClient } from "../db/patient-db-client"; -import { NotifyEventCode } from "../types/notify-message"; -import { NotifyMessageBuilder } from "./notify-message-builder"; - -describe("NotifyMessageBuilder", () => { - const mockGetPatient = jest.fn, [string]>(); - const mockGetOrderCreatedAt = jest.fn, [string]>(); - const mockGetOrderReferenceNumber = jest.fn, [string]>(); - const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); - - const mockPatientDbClient: Pick = { - get: mockGetPatient, - }; - - const mockOrderDbClient: Pick = { - getOrderCreatedAt: mockGetOrderCreatedAt, - getOrderReferenceNumber: mockGetOrderReferenceNumber, - }; - - const mockOrderStatusService: Pick = { - getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, - }; - - let builder: NotifyMessageBuilder; - - beforeEach(() => { - jest.clearAllMocks(); - mockGetPatient.mockResolvedValue({ - nhsNumber: "1234567890", - birthDate: "1990-01-02", - }); - - builder = new NotifyMessageBuilder( - mockPatientDbClient as PatientDbClient, - mockOrderDbClient as OrderDbClient, - mockOrderStatusService as OrderStatusService, - "https://hometest.example.nhs.uk", - ); - }); - - it("should build dispatched notify message with formatted date and tracking url", async () => { - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - - const result = await builder.buildOrderDispatchedNotifyMessage({ - patientId: "550e8400-e29b-41d4-a716-446655440111", - correlationId: "123e4567-e89b-12d3-a456-426614174000", - orderId: "550e8400-e29b-41d4-a716-446655440000", - }); - - expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000"); - expect(result.eventCode).toBe(NotifyEventCode.OrderDispatched); - expect(result.recipient).toEqual({ - nhsNumber: "1234567890", - dateOfBirth: "1990-01-02", - }); - - expect(result.personalisation).toEqual({ - dispatchedDate: "6 August 2026", - orderLinkUrl: - "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", - referenceNumber: "100001", - }); - - expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( - "550e8400-e29b-41d4-a716-446655440000", - OrderStatusCodes.DISPATCHED, - ); - expect(mockGetOrderReferenceNumber).toHaveBeenCalledWith( - "550e8400-e29b-41d4-a716-446655440000", - ); - }); - - it("should normalize trailing slash in base url", async () => { - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - - const trailingSlashBuilder = new NotifyMessageBuilder( - mockPatientDbClient as PatientDbClient, - mockOrderDbClient as OrderDbClient, - mockOrderStatusService as OrderStatusService, - "https://hometest.example.nhs.uk/", - ); - - const result = await trailingSlashBuilder.buildOrderDispatchedNotifyMessage({ - patientId: "550e8400-e29b-41d4-a716-446655440111", - correlationId: "123e4567-e89b-12d3-a456-426614174000", - orderId: "550e8400-e29b-41d4-a716-446655440000", - }); - - const orderLinkUrl = result.personalisation?.orderLinkUrl; - - expect(typeof orderLinkUrl).toBe("string"); - expect(orderLinkUrl).toContain( - "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", - ); - expect(orderLinkUrl).not.toContain(".uk//orders"); - }); - - it("should call recipient lookup with patient id", async () => { - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - - await builder.buildOrderDispatchedNotifyMessage({ - patientId: "550e8400-e29b-41d4-a716-446655440111", - correlationId: "123e4567-e89b-12d3-a456-426614174000", - orderId: "550e8400-e29b-41d4-a716-446655440000", - }); - - expect(mockGetPatient).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111"); - }); - - it("should build received notify message with receivedDate in personalisation", async () => { - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - - const result = await builder.buildOrderReceivedNotifyMessage({ - patientId: "550e8400-e29b-41d4-a716-446655440111", - correlationId: "123e4567-e89b-12d3-a456-426614174000", - orderId: "550e8400-e29b-41d4-a716-446655440000", - }); - - expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000"); - expect(result.eventCode).toBe(NotifyEventCode.OrderReceived); - expect(result.personalisation).toEqual({ - receivedDate: "6 August 2026", - orderLinkUrl: - "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/tracking", - referenceNumber: "100001", - }); - - expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( - "550e8400-e29b-41d4-a716-446655440000", - OrderStatusCodes.RECEIVED, - ); - }); - - it("should build result available notify message with orderedDate in personalisation", async () => { - mockGetOrderCreatedAt.mockResolvedValue("2026-08-05T10:00:00Z"); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - - const result = await builder.buildOrderResultAvailableNotifyMessage({ - patientId: "550e8400-e29b-41d4-a716-446655440111", - correlationId: "123e4567-e89b-12d3-a456-426614174000", - orderId: "550e8400-e29b-41d4-a716-446655440000", - }); - - expect(result.correlationId).toBe("123e4567-e89b-12d3-a456-426614174000"); - expect(result.eventCode).toBe(NotifyEventCode.ResultReady); - expect(result.personalisation).toEqual({ - orderedDate: "5 August 2026", - resultLinkUrl: - "https://hometest.example.nhs.uk/orders/550e8400-e29b-41d4-a716-446655440000/results", - referenceNumber: "100001", - }); - - expect(mockGetOrderCreatedAt).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440000"); - }); -}); diff --git a/lambdas/src/lib/notify/notify-message-builder.ts b/lambdas/src/lib/notify/notify-message-builder.ts deleted file mode 100644 index c64e8395..00000000 --- a/lambdas/src/lib/notify/notify-message-builder.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; - -import type { OrderDbClient } from "../db/order-db-client"; -import { OrderStatusCodes, OrderStatusService } from "../db/order-status-db"; -import type { PatientDbClient } from "../db/patient-db-client"; -import { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../types/notify-message"; - -export interface BuildOrderNotifyMessageInput { - patientId: string; - correlationId: string; - orderId: string; -} - -const formatStatusDate = (isoDateTime: string): string => - new Intl.DateTimeFormat("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - timeZone: "UTC", - }).format(new Date(isoDateTime)); - -export class NotifyMessageBuilder { - private readonly normalizedHomeTestBaseUrl: string; - - constructor( - private readonly patientDbClient: PatientDbClient, - private readonly orderDbClient: OrderDbClient, - private readonly orderStatusService: OrderStatusService, - homeTestBaseUrl: string, - ) { - this.normalizedHomeTestBaseUrl = homeTestBaseUrl.replaceAll(/\/$/g, ""); - } - - async buildOrderDispatchedNotifyMessage( - input: BuildOrderNotifyMessageInput, - ): Promise { - const { patientId, correlationId, orderId } = input; - - const dispatchedAt = await this.orderStatusService.getOrderStatusCreatedAt( - orderId, - OrderStatusCodes.DISPATCHED, - ); - - const trackingUrl = this.buildOrderTrackingUrl(orderId); - - return this.buildOrderStatusNotifyMessage({ - patientId, - correlationId, - orderId, - eventCode: NotifyEventCode.OrderDispatched, - personalisation: { - dispatchedDate: formatStatusDate(dispatchedAt), - orderLinkUrl: trackingUrl, - }, - }); - } - - async buildOrderReceivedNotifyMessage( - input: BuildOrderNotifyMessageInput, - ): Promise { - const { patientId, correlationId, orderId } = input; - - const receivedAt = await this.orderStatusService.getOrderStatusCreatedAt( - orderId, - OrderStatusCodes.RECEIVED, - ); - - const trackingUrl = this.buildOrderTrackingUrl(orderId); - - return this.buildOrderStatusNotifyMessage({ - patientId, - correlationId, - orderId, - eventCode: NotifyEventCode.OrderReceived, - personalisation: { - receivedDate: formatStatusDate(receivedAt), - orderLinkUrl: trackingUrl, - }, - }); - } - - async buildOrderResultAvailableNotifyMessage( - input: BuildOrderNotifyMessageInput, - ): Promise { - const { patientId, correlationId, orderId } = input; - - const orderCreatedAt = await this.orderDbClient.getOrderCreatedAt(orderId); - - const resultsUrl = this.buildOrderResultsUrl(orderId); - - return this.buildOrderStatusNotifyMessage({ - patientId, - correlationId, - orderId, - eventCode: NotifyEventCode.ResultReady, - personalisation: { - orderedDate: formatStatusDate(orderCreatedAt), - resultLinkUrl: resultsUrl, - }, - }); - } - - private async buildOrderStatusNotifyMessage(input: { - patientId: string; - correlationId: string; - orderId: string; - eventCode: NotifyEventCode; - personalisation: Record; - }): Promise { - const { patientId, correlationId, orderId, eventCode, personalisation } = input; - - const patient = await this.patientDbClient.get(patientId); - const recipient: NotifyRecipient = { - nhsNumber: patient.nhsNumber, - dateOfBirth: patient.birthDate, - }; - - const referenceNumber = await this.orderDbClient.getOrderReferenceNumber(orderId); - - return { - correlationId, - messageReference: uuidv4(), - eventCode, - recipient, - personalisation: { - ...personalisation, - referenceNumber: referenceNumber, - }, - }; - } - - private buildOrderTrackingUrl(orderId: string): string { - return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; - } - - private buildOrderResultsUrl(orderId: string): string { - return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/results`; - } -} diff --git a/lambdas/src/lib/notify/notify-service.ts b/lambdas/src/lib/notify/notify-service.ts deleted file mode 100644 index 87f59e04..00000000 --- a/lambdas/src/lib/notify/notify-service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ConsoleCommons } from "../commons"; -import { - NotificationAuditDbClient, - NotificationAuditStatus, -} from "../db/notification-audit-db-client"; -import { OrderStatusCode, OrderStatusCodes, OrderStatusService } from "../db/order-status-db"; -import { SQSClientInterface } from "../sqs/sqs-client"; -import type { NotifyMessage } from "../types/notify-message"; -import { NotifyMessageBuilder } from "./notify-message-builder"; - -const commons = new ConsoleCommons(); -const name = "notify-service"; - -export interface OrderStatusNotifyServiceDependencies { - orderStatusDb: OrderStatusService; - notificationAuditDbClient: NotificationAuditDbClient; - sqsClient: SQSClientInterface; - notifyMessageBuilder: NotifyMessageBuilder; - notifyMessagesQueueUrl: string; -} - -export interface HandleOrderStatusUpdatedInput { - orderId: string; - patientId: string; - correlationId: string; - statusCode: OrderStatusCode; -} - -interface BuildNotifyMessageInput { - orderId: string; - patientId: string; - correlationId: string; -} - -type NotifyMessageBuilderByStatus = Partial< - Record Promise> ->; - -export class OrderStatusNotifyService { - constructor(private readonly dependencies: OrderStatusNotifyServiceDependencies) {} - - async handleOrderStatusUpdated( - handleOrderStatusUpdatedInput: HandleOrderStatusUpdatedInput, - ): Promise { - const { statusCode } = handleOrderStatusUpdatedInput; - const { notifyMessageBuilder } = this.dependencies; - - const buildNotifyMessageByStatus: NotifyMessageBuilderByStatus = { - [OrderStatusCodes.DISPATCHED]: ({ patientId, correlationId, orderId }) => - notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ - patientId, - correlationId, - orderId, - }), - [OrderStatusCodes.RECEIVED]: ({ patientId, correlationId, orderId }) => - notifyMessageBuilder.buildOrderReceivedNotifyMessage({ - patientId, - correlationId, - orderId, - }), - [OrderStatusCodes.COMPLETE]: ({ patientId, correlationId, orderId }) => - notifyMessageBuilder.buildOrderResultAvailableNotifyMessage({ - patientId, - correlationId, - orderId, - }), - }; - - const buildNotifyMessageFunc = buildNotifyMessageByStatus[statusCode]; - - if (!buildNotifyMessageFunc) { - return; - } - - await this.handleStatusUpdated(handleOrderStatusUpdatedInput, buildNotifyMessageFunc); - } - - private async handleStatusUpdated( - input: HandleOrderStatusUpdatedInput, - buildNotifyMessage: (input: BuildNotifyMessageInput) => Promise, - ): Promise { - const { orderId, patientId, correlationId, statusCode } = input; - const { notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl } = this.dependencies; - - try { - const notifyMessage = await buildNotifyMessage({ - patientId, - correlationId, - orderId, - }); - - await sqsClient.sendMessage(notifyMessagesQueueUrl, JSON.stringify(notifyMessage)); - - await notificationAuditDbClient.insertNotificationAuditEntry({ - messageReference: notifyMessage.messageReference, - eventCode: notifyMessage.eventCode, - correlationId, - status: NotificationAuditStatus.QUEUED, - }); - } catch (error) { - commons.logError(name, "Failed to send status notification", { - correlationId, - orderId, - statusCode, - error, - }); - } - } -} diff --git a/lambdas/src/lib/notify/services/base-notify-service.ts b/lambdas/src/lib/notify/services/base-notify-service.ts new file mode 100644 index 00000000..d0654a9a --- /dev/null +++ b/lambdas/src/lib/notify/services/base-notify-service.ts @@ -0,0 +1,57 @@ +import { ConsoleCommons } from "../../commons"; +import { + NotificationAuditDbClient, + NotificationAuditStatus, +} from "../../db/notification-audit-db-client"; +import { SQSClientInterface } from "../../sqs/sqs-client"; +import { type NotifyMessage } from "../../types/notify-message"; + +const commons = new ConsoleCommons(); +const name = "notify-service"; + +export interface NotifyServiceDependencies { + notificationAuditDbClient: NotificationAuditDbClient; + sqsClient: SQSClientInterface; + notifyMessagesQueueUrl: string; +} + +export abstract class BaseNotifyService { + constructor(protected readonly dependencies: NotifyServiceDependencies) {} + + protected async dispatchNotification( + notifyMessage: NotifyMessage, + orderId: string, + ): Promise { + const { correlationId } = notifyMessage; + const { notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl } = this.dependencies; + + try { + const sqsResult = await sqsClient.sendMessage( + notifyMessagesQueueUrl, + JSON.stringify(notifyMessage), + ); + + await notificationAuditDbClient.insertNotificationAuditEntry({ + messageReference: notifyMessage.messageReference, + eventCode: notifyMessage.eventCode, + correlationId, + status: NotificationAuditStatus.QUEUED, + }); + + commons.logInfo(name, "Notification dispatched", { + correlationId, + orderId, + eventCode: notifyMessage.eventCode, + messageId: sqsResult.messageId, + messageReference: notifyMessage.messageReference, + }); + } catch (error) { + commons.logError(name, "Failed to dispatch notification", { + correlationId, + orderId, + eventCode: notifyMessage.eventCode, + error, + }); + } + } +} diff --git a/lambdas/src/lib/notify/notify-service.test.ts b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts similarity index 77% rename from lambdas/src/lib/notify/notify-service.test.ts rename to lambdas/src/lib/notify/services/order-status-notify-service.test.ts index 52ed0bf0..a516beab 100644 --- a/lambdas/src/lib/notify/notify-service.test.ts +++ b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts @@ -1,7 +1,14 @@ -import { NotificationAuditStatus } from "../db/notification-audit-db-client"; -import { OrderStatusCodes, OrderStatusUpdateParams } from "../db/order-status-db"; -import { NotifyEventCode } from "../types/notify-message"; -import { OrderStatusNotifyService } from "./notify-service"; +import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; +import { OrderStatusCodes, OrderStatusUpdateParams } from "../../db/order-status-db"; +import { NotifyEventCode } from "../../types/notify-message"; +import { OrderDispatchedMessageBuilder } from "../message-builders/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../message-builders/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../message-builders/order-result-available-message-builder"; +import { OrderStatusNotifyService } from "./order-status-notify-service"; + +jest.mock("../message-builders/order-dispatched-message-builder"); +jest.mock("../message-builders/order-received-message-builder"); +jest.mock("../message-builders/order-result-available-message-builder"); describe("OrderStatusNotifyService", () => { const mockBuildOrderDispatchedNotifyMessage = jest.fn(); @@ -22,6 +29,16 @@ describe("OrderStatusNotifyService", () => { beforeEach(() => { jest.clearAllMocks(); + (OrderDispatchedMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ + build: mockBuildOrderDispatchedNotifyMessage, + })); + (OrderReceivedMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ + build: mockBuildOrderReceivedNotifyMessage, + })); + (OrderResultAvailableMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ + build: mockBuildOrderResultAvailableNotifyMessage, + })); + mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ messageReference: "123e4567-e89b-12d3-a456-426614174099", eventCode: NotifyEventCode.OrderDispatched, @@ -56,24 +73,24 @@ describe("OrderStatusNotifyService", () => { mockInsertNotificationAuditEntry.mockResolvedValue(undefined); service = new OrderStatusNotifyService({ - orderStatusDb: {} as never, + builderDeps: { + patientDbClient: {} as never, + orderDbClient: {} as never, + homeTestBaseUrl: "https://hometest.example.nhs.uk", + }, + orderStatusService: {} as never, notificationAuditDbClient: { insertNotificationAuditEntry: mockInsertNotificationAuditEntry, } as never, sqsClient: { sendMessage: mockSendMessage, }, - notifyMessageBuilder: { - buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, - buildOrderReceivedNotifyMessage: mockBuildOrderReceivedNotifyMessage, - buildOrderResultAvailableNotifyMessage: mockBuildOrderResultAvailableNotifyMessage, - } as never, notifyMessagesQueueUrl: "https://example.queue.local/notify", }); }); it("should do nothing for statuses without side effects", async () => { - await service.handleOrderStatusUpdated({ + await service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, @@ -88,7 +105,7 @@ describe("OrderStatusNotifyService", () => { }); it("should send and audit a dispatched notification", async () => { - await service.handleOrderStatusUpdated({ + await service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, @@ -113,7 +130,7 @@ describe("OrderStatusNotifyService", () => { }); it("should send and audit a received notification", async () => { - await service.handleOrderStatusUpdated({ + await service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, @@ -138,7 +155,7 @@ describe("OrderStatusNotifyService", () => { }); it("should send and audit a result available notification", async () => { - await service.handleOrderStatusUpdated({ + await service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, @@ -162,19 +179,19 @@ describe("OrderStatusNotifyService", () => { }); }); - it("should swallow errors when building the notify message fails", async () => { + it("should propagate errors when building the notify message fails", async () => { mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( new Error("Notify payload build failed"), ); await expect( - service.handleOrderStatusUpdated({ + service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, statusCode: OrderStatusCodes.DISPATCHED, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("Notify payload build failed"); expect(mockSendMessage).not.toHaveBeenCalled(); expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); @@ -184,7 +201,7 @@ describe("OrderStatusNotifyService", () => { mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); await expect( - service.handleOrderStatusUpdated({ + service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, @@ -195,19 +212,19 @@ describe("OrderStatusNotifyService", () => { expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); }); - it("should swallow errors when building the received notify message fails", async () => { + it("should propagate errors when building the received notify message fails", async () => { mockBuildOrderReceivedNotifyMessage.mockRejectedValueOnce( new Error("Notify payload build failed"), ); await expect( - service.handleOrderStatusUpdated({ + service.dispatch({ orderId: statusUpdate.orderId, patientId: "patient-123", correlationId: statusUpdate.correlationId, statusCode: OrderStatusCodes.RECEIVED, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("Notify payload build failed"); expect(mockSendMessage).not.toHaveBeenCalled(); expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); diff --git a/lambdas/src/lib/notify/services/order-status-notify-service.ts b/lambdas/src/lib/notify/services/order-status-notify-service.ts new file mode 100644 index 00000000..20a22c64 --- /dev/null +++ b/lambdas/src/lib/notify/services/order-status-notify-service.ts @@ -0,0 +1,54 @@ +import { + type OrderStatusCode, + OrderStatusCodes, + OrderStatusService, +} from "../../db/order-status-db"; +import { type NotifyMessage } from "../../types/notify-message"; +import { type NotifyMessageBuilderDependencies } from "../message-builders/base-notify-message-builder"; +import { OrderDispatchedMessageBuilder } from "../message-builders/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../message-builders/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../message-builders/order-result-available-message-builder"; +import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service"; + +export interface OrderStatusNotifyServiceDependencies extends NotifyServiceDependencies { + builderDeps: NotifyMessageBuilderDependencies; + orderStatusService: OrderStatusService; +} + +export interface OrderStatusNotifyInput { + orderId: string; + patientId: string; + correlationId: string; + statusCode: OrderStatusCode; +} + +export class OrderStatusNotifyService extends BaseNotifyService { + constructor(private readonly orderStatusDeps: OrderStatusNotifyServiceDependencies) { + super(orderStatusDeps); + } + + async dispatch(input: OrderStatusNotifyInput): Promise { + const { statusCode, orderId, patientId, correlationId } = input; + const { builderDeps, orderStatusService } = this.orderStatusDeps; + + const builderInput = { orderId, patientId, correlationId }; + + let builder: { build: (input: typeof builderInput) => Promise }; + switch (statusCode) { + case OrderStatusCodes.DISPATCHED: + builder = new OrderDispatchedMessageBuilder(builderDeps, orderStatusService); + break; + case OrderStatusCodes.RECEIVED: + builder = new OrderReceivedMessageBuilder(builderDeps, orderStatusService); + break; + case OrderStatusCodes.COMPLETE: + builder = new OrderResultAvailableMessageBuilder(builderDeps); + break; + default: + return; + } + + const notifyMessage = await builder.build(builderInput); + await this.dispatchNotification(notifyMessage, orderId); + } +} diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts new file mode 100644 index 00000000..7c60979d --- /dev/null +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -0,0 +1,138 @@ +import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; +import { OrderStatusCodes } from "../../db/order-status-db"; +import { NotifyEventCode } from "../../types/notify-message"; +import { DispatchedReminderMessageBuilder } from "../message-builders/dispatched-reminder-message-builder"; +import { + ReminderNotifyService, + type ReminderNotifyServiceDependencies, +} from "./reminder-notify-service"; + +jest.mock("../message-builders/dispatched-reminder-message-builder"); + +describe("ReminderNotifyService", () => { + const mockGetPatientIdFromOrder = jest.fn(); + const mockBuildDispatchedReminderMessage = jest.fn(); + const mockSendMessage = jest.fn(); + const mockInsertNotificationAuditEntry = jest.fn(); + + const orderId = "550e8400-e29b-41d4-a716-446655440000"; + const correlationId = "123e4567-e89b-12d3-a456-426614174000"; + const reminderId = "223e4567-e89b-12d3-a456-426614174444"; + + let service: ReminderNotifyService; + + const buildService = (deps?: Partial): ReminderNotifyService => + new ReminderNotifyService({ + builderDeps: { + patientDbClient: {} as never, + orderDbClient: {} as never, + homeTestBaseUrl: "https://hometest.example.nhs.uk", + }, + orderStatusService: { + getPatientIdFromOrder: mockGetPatientIdFromOrder, + } as never, + notificationAuditDbClient: { + insertNotificationAuditEntry: mockInsertNotificationAuditEntry, + } as never, + sqsClient: { + sendMessage: mockSendMessage, + }, + notifyMessagesQueueUrl: "https://example.queue.local/notify", + ...deps, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (DispatchedReminderMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ + build: mockBuildDispatchedReminderMessage, + })); + mockGetPatientIdFromOrder.mockResolvedValue("patient-123"); + mockBuildDispatchedReminderMessage.mockResolvedValue({ + messageReference: reminderId, + eventCode: NotifyEventCode.DispatchedInitialReminder, + correlationId, + recipient: { nhsNumber: "1234567890", dateOfBirth: "1990-01-02" }, + personalisation: {}, + }); + mockSendMessage.mockResolvedValue({ messageId: "sqs-message-id" }); + mockInsertNotificationAuditEntry.mockResolvedValue(undefined); + + service = buildService(); + }); + + it("dispatches and audits a reminder notification for a dispatched order", async () => { + await service.dispatch({ + reminderId, + orderId, + correlationId, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + + expect(mockBuildDispatchedReminderMessage).toHaveBeenCalledWith({ + reminderId, + patientId: "patient-123", + correlationId, + orderId, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + expect(mockSendMessage).toHaveBeenCalledWith( + "https://example.queue.local/notify", + expect.any(String), + ); + expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({ + messageReference: reminderId, + eventCode: NotifyEventCode.DispatchedInitialReminder, + correlationId, + status: NotificationAuditStatus.QUEUED, + }); + }); + + it("does nothing for a status code with no strategy", async () => { + await service.dispatch({ + reminderId, + orderId, + correlationId, + statusCode: OrderStatusCodes.SUBMITTED, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + + expect(mockGetPatientIdFromOrder).not.toHaveBeenCalled(); + expect(mockBuildDispatchedReminderMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("does not dispatch when patient is not found", async () => { + mockGetPatientIdFromOrder.mockResolvedValueOnce(null); + + await service.dispatch({ + reminderId, + orderId, + correlationId, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedSecondReminder, + }); + + expect(mockBuildDispatchedReminderMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("propagates errors when building the reminder notify message fails", async () => { + mockBuildDispatchedReminderMessage.mockRejectedValueOnce(new Error("builder failed")); + + await expect( + service.dispatch({ + reminderId, + orderId, + correlationId, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }), + ).rejects.toThrow("builder failed"); + + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.ts b/lambdas/src/lib/notify/services/reminder-notify-service.ts new file mode 100644 index 00000000..df9b4975 --- /dev/null +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -0,0 +1,61 @@ +import { ConsoleCommons } from "../../commons"; +import { + type OrderStatusCode, + OrderStatusCodes, + OrderStatusService, +} from "../../db/order-status-db"; +import { type NotifyMessageBuilderDependencies } from "../message-builders/base-notify-message-builder"; +import { DispatchedReminderMessageBuilder } from "../message-builders/dispatched-reminder-message-builder"; +import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service"; + +const commons = new ConsoleCommons(); +const name = "reminder-notify-service"; + +export interface ReminderNotifyServiceDependencies extends NotifyServiceDependencies { + builderDeps: NotifyMessageBuilderDependencies; + orderStatusService: OrderStatusService; +} + +export interface ReminderNotifyInput { + reminderId: string; + orderId: string; + correlationId: string; + statusCode: OrderStatusCode; + eventCode: string; +} + +export class ReminderNotifyService extends BaseNotifyService { + constructor(private readonly reminderDeps: ReminderNotifyServiceDependencies) { + super(reminderDeps); + } + + async dispatch(input: ReminderNotifyInput): Promise { + const { reminderId, orderId, correlationId, statusCode, eventCode } = input; + const { builderDeps, orderStatusService } = this.reminderDeps; + + if (statusCode !== OrderStatusCodes.DISPATCHED) { + return; + } + + const patientId = await orderStatusService.getPatientIdFromOrder(orderId); + + if (!patientId) { + commons.logError(name, "Patient not found for reminder notification", { + correlationId, + orderId, + }); + return; + } + + const builder = new DispatchedReminderMessageBuilder(builderDeps, orderStatusService); + const notifyMessage = await builder.build({ + reminderId, + patientId, + orderId, + correlationId, + eventCode, + }); + + await this.dispatchNotification(notifyMessage, orderId); + } +} diff --git a/lambdas/src/lib/types/notify-message.ts b/lambdas/src/lib/types/notify-message.ts index cd4bdc48..6d14fd95 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -1,7 +1,7 @@ export interface NotifyMessage { correlationId: string; messageReference: string; - eventCode: NotifyEventCode; + eventCode: string; recipient: NotifyRecipient; personalisation?: Record; } @@ -14,6 +14,8 @@ export interface NotifyRecipient { export enum NotifyEventCode { OrderConfirmed = "ORDER_CONFIRMED", OrderDispatched = "ORDER_DISPATCHED", + DispatchedInitialReminder = "DISPATCHED_INITIAL_REMINDER", + DispatchedSecondReminder = "DISPATCHED_SECOND_REMINDER", OrderReceived = "ORDER_RECEIVED", ResultReady = "RESULT_READY", } diff --git a/lambdas/src/order-result-lambda/index.test.ts b/lambdas/src/order-result-lambda/index.test.ts index 0fb1481d..94b0b510 100644 --- a/lambdas/src/order-result-lambda/index.test.ts +++ b/lambdas/src/order-result-lambda/index.test.ts @@ -16,7 +16,7 @@ jest.mock("./init", () => { updateOrderStatusAndResultStatus: jest.fn(), }, orderStatusNotifyService: { - handleOrderStatusUpdated: jest.fn(), + dispatch: jest.fn(), }, }; return { @@ -74,7 +74,7 @@ const { initMock } = jest.requireMock("./init") as { updateOrderStatusAndResultStatus: jest.Mock; }; orderStatusNotifyService: { - handleOrderStatusUpdated: jest.Mock; + dispatch: jest.Mock; }; }; }; @@ -125,7 +125,7 @@ describe("order-result-lambda handler", () => { validateDBDataMock.mockResolvedValue({ success: true, data: { isIdempotent: false } }); extractInterpretationCodeFromFHIRObservationMock.mockReturnValue(InterpretationCode.Normal); initMock.orderService.updateOrderStatusAndResultStatus.mockResolvedValue(undefined); - initMock.orderStatusNotifyService.handleOrderStatusUpdated.mockResolvedValue(undefined); + initMock.orderStatusNotifyService.dispatch.mockResolvedValue(undefined); }); it("returns 201 and resource on success", async () => { @@ -192,7 +192,7 @@ describe("order-result-lambda handler", () => { ResultStatus.Result_Available, identifiers.correlationId, ); - expect(initMock.orderStatusNotifyService.handleOrderStatusUpdated).toHaveBeenCalledWith( + expect(initMock.orderStatusNotifyService.dispatch).toHaveBeenCalledWith( expect.objectContaining({ orderId: identifiers.orderUid, patientId: expect.any(String), @@ -213,7 +213,7 @@ describe("order-result-lambda handler", () => { ResultStatus.Result_Withheld, identifiers.correlationId, ); - expect(initMock.orderStatusNotifyService.handleOrderStatusUpdated).not.toHaveBeenCalled(); + expect(initMock.orderStatusNotifyService.dispatch).not.toHaveBeenCalled(); }); it("returns 500 if updateDatabase throws", async () => { diff --git a/lambdas/src/order-result-lambda/index.ts b/lambdas/src/order-result-lambda/index.ts index 16a66af4..a080999c 100644 --- a/lambdas/src/order-result-lambda/index.ts +++ b/lambdas/src/order-result-lambda/index.ts @@ -116,7 +116,7 @@ export const handler = async (event: APIGatewayProxyEvent): Promise { const originalEnv = process.env; @@ -115,27 +113,20 @@ describe("order-result-lambda init", () => { expect(ConsoleCommons).toHaveBeenCalledWith(); }); - it("should create NotifyMessageBuilder with homeTestBaseUrl", () => { - init(); - - expect(NotifyMessageBuilder).toHaveBeenCalledWith( - expect.any(PatientDbClient), - expect.any(OrderDbClient), - expect.any(OrderStatusService), - "https://hometest.example.nhs.uk", - ); - }); - it("should create OrderStatusNotifyService with notifyMessagesQueueUrl", () => { init(); expect(OrderStatusNotifyService).toHaveBeenCalledWith( expect.objectContaining({ + builderDeps: { + patientDbClient: expect.any(PatientDbClient), + orderDbClient: expect.any(OrderDbClient), + homeTestBaseUrl: "https://hometest.example.nhs.uk", + }, + orderStatusService: expect.any(OrderStatusService), notifyMessagesQueueUrl: "https://example.queue.local/notify", - orderStatusDb: expect.any(OrderStatusService), notificationAuditDbClient: expect.any(NotificationAuditDbClient), sqsClient: expect.any(AWSSQSClient), - notifyMessageBuilder: expect.any(NotifyMessageBuilder), }), ); }); diff --git a/lambdas/src/order-result-lambda/init.ts b/lambdas/src/order-result-lambda/init.ts index 56609d0a..ed3cea9d 100644 --- a/lambdas/src/order-result-lambda/init.ts +++ b/lambdas/src/order-result-lambda/init.ts @@ -6,8 +6,7 @@ import { OrderService } from "../lib/db/order-db"; import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; -import { NotifyMessageBuilder } from "../lib/notify/notify-message-builder"; -import { OrderStatusNotifyService } from "../lib/notify/notify-service"; +import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; @@ -32,17 +31,11 @@ export function buildEnvironment(): Environment { const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); - const notifyMessageBuilder = new NotifyMessageBuilder( - patientDbClient, - orderDbClient, - orderStatusDb, - homeTestBaseUrl, - ); const orderStatusNotifyService = new OrderStatusNotifyService({ - orderStatusDb, + builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, + orderStatusService: orderStatusDb, notificationAuditDbClient, sqsClient, - notifyMessageBuilder, notifyMessagesQueueUrl, }); diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 74c812bb..b5e9a65a 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -10,7 +10,7 @@ const mockInit = jest.fn(); const mockGetPatientIdFromOrder = jest.fn(); const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); -const mockHandleOrderStatusUpdated = jest.fn(); +const mockNotify = jest.fn(); const mockGetCorrelationIdFromEventHeaders = jest.fn(); @@ -42,7 +42,7 @@ describe("Order Status Lambda Handler", () => { mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); - mockHandleOrderStatusUpdated.mockResolvedValue(undefined); + mockNotify.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { @@ -51,7 +51,7 @@ describe("Order Status Lambda Handler", () => { addOrderStatusUpdate: mockAddOrderStatusUpdate, }, orderStatusNotifyService: { - handleOrderStatusUpdated: mockHandleOrderStatusUpdated, + dispatch: mockNotify, }, }); @@ -277,7 +277,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(200); expect(mockCheckIdempotency).toHaveBeenCalledWith(MOCK_ORDER_UID, MOCK_CORRELATION_ID); - expect(mockHandleOrderStatusUpdated).not.toHaveBeenCalled(); + expect(mockNotify).not.toHaveBeenCalled(); }); it("should process new updates with different correlation ID", async () => { @@ -411,7 +411,7 @@ describe("Order Status Lambda Handler", () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); - expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( + expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ patientId: MOCK_PATIENT_UID, correlationId: MOCK_CORRELATION_ID, @@ -432,7 +432,7 @@ describe("Order Status Lambda Handler", () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); - expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( + expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], }), @@ -440,7 +440,7 @@ describe("Order Status Lambda Handler", () => { }); it("should return 500 when notification service fails", async () => { - mockHandleOrderStatusUpdated.mockRejectedValueOnce(new Error("Unexpected side effect error")); + mockNotify.mockRejectedValueOnce(new Error("Unexpected side effect error")); mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 4cb9a82e..b7824f99 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -161,7 +161,7 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); - await orderStatusNotifyService.handleOrderStatusUpdated({ + await orderStatusNotifyService.dispatch({ orderId, patientId: orderPatientId, correlationId, diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index b5e02256..8834863b 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -4,8 +4,7 @@ import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-clien import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; -import { NotifyMessageBuilder } from "../lib/notify/notify-message-builder"; -import { OrderStatusNotifyService } from "../lib/notify/notify-service"; +import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { testComponentCreationOrder } from "../lib/test-utils/component-integration-helpers"; @@ -19,8 +18,7 @@ jest.mock("../lib/db/db-client"); jest.mock("../lib/secrets/secrets-manager-client"); jest.mock("../lib/sqs/sqs-client"); jest.mock("../lib/db/db-config"); -jest.mock("../lib/notify/notify-message-builder"); -jest.mock("../lib/notify/notify-service"); +jest.mock("../lib/notify/services/order-status-notify-service"); describe("init", () => { const originalEnv = process.env; @@ -161,10 +159,6 @@ describe("init", () => { mock: AWSSQSClient as jest.Mock, times: 1, }, - { - mock: NotifyMessageBuilder as jest.Mock, - times: 1, - }, { mock: OrderStatusNotifyService as jest.Mock, times: 1, @@ -173,25 +167,18 @@ describe("init", () => { }); }); - it("should create NotifyMessageBuilder with PatientDbClient and home test base url", () => { - init(); - - expect(NotifyMessageBuilder).toHaveBeenCalledWith( - expect.any(PatientDbClient), - expect.any(OrderDbClient), - expect.any(OrderStatusService), - "https://hometest.example.nhs.uk", - ); - }); - it("should create OrderStatusNotifyService with notification dependencies", () => { init(); expect(OrderStatusNotifyService).toHaveBeenCalledWith({ - orderStatusDb: expect.any(OrderStatusService), + builderDeps: { + patientDbClient: expect.any(PatientDbClient), + orderDbClient: expect.any(OrderDbClient), + homeTestBaseUrl: "https://hometest.example.nhs.uk", + }, + orderStatusService: expect.any(OrderStatusService), notificationAuditDbClient: expect.any(NotificationAuditDbClient), sqsClient: expect.any(AWSSQSClient), - notifyMessageBuilder: expect.any(NotifyMessageBuilder), notifyMessagesQueueUrl: "https://example.queue.local/notify", }); }); diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index cfec86a2..5a9654f3 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -4,8 +4,7 @@ import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-clien import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; -import { NotifyMessageBuilder } from "../lib/notify/notify-message-builder"; -import { OrderStatusNotifyService } from "../lib/notify/notify-service"; +import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; @@ -27,17 +26,11 @@ export function buildEnvironment(): Environment { const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); - const notifyMessageBuilder = new NotifyMessageBuilder( - patientDbClient, - orderDbClient, - orderStatusDb, - homeTestBaseUrl, - ); const orderStatusNotifyService = new OrderStatusNotifyService({ - orderStatusDb, + builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, + orderStatusService: orderStatusDb, notificationAuditDbClient, sqsClient, - notifyMessageBuilder, notifyMessagesQueueUrl, }); diff --git a/lambdas/src/reminder-dispatch-lambda/config.ts b/lambdas/src/reminder-dispatch-lambda/config.ts new file mode 100644 index 00000000..ec965b12 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/config.ts @@ -0,0 +1,96 @@ +import { type OrderStatusCode, OrderStatusCodes } from "../lib/db/order-status-db"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; + +const allOrderStatusCodes = new Set(Object.values(OrderStatusCodes)); + +export interface ReminderScheduleConfig { + interval: number; + eventCode: string; +} + +export type ReminderConfiguration = Partial>; + +export interface ReminderDispatchConfig { + enabledReminderStatuses: ReadonlySet; + reminderConfiguration: ReminderConfiguration; +} + +function isOrderStatusCode(value: string): value is OrderStatusCode { + return allOrderStatusCodes.has(value as OrderStatusCode); +} + +function parseEnabledReminderStatuses(rawValue: string): ReadonlySet { + const parsed = JSON.parse(rawValue) as unknown; + + if (!Array.isArray(parsed)) { + throw new Error("REMINDER_ENABLED_STATUSES must be a JSON array of order status strings"); + } + + const enabledStatuses = parsed.filter( + (status): status is OrderStatusCode => typeof status === "string" && isOrderStatusCode(status), + ); + + if (enabledStatuses.length === 0) { + throw new Error("REMINDER_ENABLED_STATUSES must contain at least one valid order status"); + } + + return new Set(enabledStatuses); +} + +function parseReminderConfiguration(rawValue: string): ReminderConfiguration { + const parsed = JSON.parse(rawValue) as unknown; + + if (!parsed || typeof parsed !== "object") { + throw new Error("REMINDER_INTERVAL_CONFIG must be a JSON object"); + } + + const result: ReminderConfiguration = {}; + + for (const [status, schedules] of Object.entries(parsed)) { + if (!isOrderStatusCode(status)) { + continue; + } + + if (!Array.isArray(schedules)) { + continue; + } + + const validSchedules = schedules + .map((schedule): ReminderScheduleConfig | null => { + if (!schedule || typeof schedule !== "object") { + return null; + } + + const rawInterval = (schedule as { interval?: unknown }).interval; + const rawEventCode = (schedule as { eventCode?: unknown }).eventCode; + + if (typeof rawInterval !== "number" || !Number.isFinite(rawInterval) || rawInterval <= 0) { + return null; + } + + if (typeof rawEventCode !== "string" || !rawEventCode.trim()) { + return null; + } + + return { + interval: rawInterval, + eventCode: rawEventCode, + }; + }) + .filter((schedule): schedule is ReminderScheduleConfig => schedule !== null); + + result[status] = validSchedules; + } + + return result; +} + +export function getReminderDispatchConfigFromEnv(): ReminderDispatchConfig { + const enabledStatusesRaw = retrieveMandatoryEnvVariable("REMINDER_ENABLED_STATUSES"); + const reminderIntervalConfigRaw = retrieveMandatoryEnvVariable("REMINDER_INTERVAL_CONFIG"); + + return { + enabledReminderStatuses: parseEnabledReminderStatuses(enabledStatusesRaw), + reminderConfiguration: parseReminderConfiguration(reminderIntervalConfigRaw), + }; +} diff --git a/lambdas/src/reminder-dispatch-lambda/index.test.ts b/lambdas/src/reminder-dispatch-lambda/index.test.ts new file mode 100644 index 00000000..8656cc03 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/index.test.ts @@ -0,0 +1,127 @@ +import { Context, EventBridgeEvent } from "aws-lambda"; + +import { OrderStatusCodes } from "../lib/db/order-status-db"; +import { type OrderStatusReminderRecord } from "../lib/db/order-status-reminder-db-client"; +import { NotifyEventCode } from "../lib/types/notify-message"; +import { lambdaHandler } from "./index"; +import { init } from "./init"; + +jest.mock("./init", () => ({ + init: jest.fn(), +})); + +const DISPATCHED_REMINDER_1: OrderStatusReminderRecord = { + reminderId: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", + orderUid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", + statusCode: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, +}; + +const DISPATCHED_REMINDER_2: OrderStatusReminderRecord = { + reminderId: "2ddb4bcb-ee7f-4f89-a126-30e56fc23338", + orderUid: "7f97f8a4-75f3-47dc-8faf-f7f9ca6ec1ac", + statusCode: OrderStatusCodes.DISPATCHED, + reminderNumber: 2, +}; + +describe("reminder-dispatch-lambda", () => { + const mockNotify = jest.fn, [unknown]>().mockResolvedValue(undefined); + const mockGetPendingReminders = jest.fn, []>(); + + const mockedInit = jest.mocked(init); + + const mockEvent = { + id: "75085c10-f0f6-4e9c-b8e1-093432fedfc4", + version: "0", + account: "123456789012", + time: "2026-04-13T10:00:00Z", + region: "eu-west-2", + resources: [], + source: "hometest.reminders", + "detail-type": "ReminderDispatchEvent", + detail: {}, + } as EventBridgeEvent<"ReminderDispatchEvent", Record>; + + beforeEach(() => { + jest.clearAllMocks(); + + process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([OrderStatusCodes.DISPATCHED]); + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: NotifyEventCode.DispatchedInitialReminder }, + { interval: 14, eventCode: NotifyEventCode.DispatchedSecondReminder }, + ], + }); + + mockGetPendingReminders.mockResolvedValue([DISPATCHED_REMINDER_1, DISPATCHED_REMINDER_2]); + + mockedInit.mockReturnValue({ + reminderNotifyService: { + dispatch: mockNotify, + }, + orderStatusReminderDbClient: { + getPendingReminders: mockGetPendingReminders, + }, + } as unknown as ReturnType); + }); + + afterEach(() => { + delete process.env.REMINDER_ENABLED_STATUSES; + delete process.env.REMINDER_INTERVAL_CONFIG; + }); + + it("calls notify for each pending reminder with the correct arguments", async () => { + await lambdaHandler(mockEvent, {} as Context); + + expect(mockNotify).toHaveBeenCalledTimes(2); + + expect(mockNotify.mock.calls[0][0]).toMatchObject({ + reminderId: DISPATCHED_REMINDER_1.reminderId, + orderId: DISPATCHED_REMINDER_1.orderUid, + correlationId: mockEvent.id, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + + expect(mockNotify.mock.calls[1][0]).toMatchObject({ + reminderId: DISPATCHED_REMINDER_2.reminderId, + orderId: DISPATCHED_REMINDER_2.orderUid, + correlationId: mockEvent.id, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedSecondReminder, + }); + }); + + it("skips reminders whose status is not in the enabled set", async () => { + process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([OrderStatusCodes.RECEIVED]); + + await lambdaHandler(mockEvent, {} as Context); + + expect(mockNotify).not.toHaveBeenCalled(); + }); + + it("skips reminders with no matching event code in the configuration", async () => { + // Only one schedule configured — reminder 2 has no eventCode + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: NotifyEventCode.DispatchedInitialReminder }, + ], + }); + + await lambdaHandler(mockEvent, {} as Context); + + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify.mock.calls[0][0]).toMatchObject({ + reminderId: DISPATCHED_REMINDER_1.reminderId, + eventCode: NotifyEventCode.DispatchedInitialReminder, + }); + }); + + it("throws and re-throws when getPendingReminders rejects", async () => { + const error = new Error("DB connection failed"); + mockGetPendingReminders.mockRejectedValueOnce(error); + + await expect(lambdaHandler(mockEvent, {} as Context)).rejects.toThrow("DB connection failed"); + expect(mockNotify).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/src/reminder-dispatch-lambda/index.ts b/lambdas/src/reminder-dispatch-lambda/index.ts new file mode 100644 index 00000000..7bafad40 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/index.ts @@ -0,0 +1,90 @@ +import { Context, EventBridgeEvent } from "aws-lambda"; + +import { ConsoleCommons } from "../lib/commons"; +import { type OrderStatusReminderRecord } from "../lib/db/order-status-reminder-db-client"; +import { type ReminderConfiguration, getReminderDispatchConfigFromEnv } from "./config"; +import { init } from "./init"; + +const commons = new ConsoleCommons(); +const name = "reminder-dispatch-lambda"; + +function getReminderEventCode( + reminder: OrderStatusReminderRecord, + reminderConfiguration: ReminderConfiguration, +): string | undefined { + const schedules = reminderConfiguration[reminder.statusCode]; + + if (!schedules || schedules.length === 0) { + return undefined; + } + + return schedules[reminder.reminderNumber - 1]?.eventCode; +} + +export const lambdaHandler = async ( + event: EventBridgeEvent<"ReminderDispatchEvent", unknown>, + _context: Context, +): Promise => { + const { reminderNotifyService, orderStatusReminderDbClient } = init(); + const { enabledReminderStatuses, reminderConfiguration } = getReminderDispatchConfigFromEnv(); + const correlationId = event.id; + + try { + commons.logInfo(name, "Reminder dispatch event received", { + correlationId, + source: event.source, + detailType: event["detail-type"], + }); + + // cleanup stale rows HOTE-1136 + // todo implement proper database method need HOTE-1125 + const reminders = await orderStatusReminderDbClient.getPendingReminders(); + + for (const reminder of reminders) { + if (!enabledReminderStatuses.has(reminder.statusCode)) { + commons.logInfo(name, "Reminder skipped for disabled trigger status", { + correlationId, + reminderId: reminder.reminderId, + orderUid: reminder.orderUid, + statusCode: reminder.statusCode, + }); + continue; + } + + const reminderEventCode = getReminderEventCode(reminder, reminderConfiguration); + + if (!reminderEventCode) { + commons.logInfo(name, "No reminder event code configured", { + correlationId, + reminderId: reminder.reminderId, + reminderNumber: reminder.reminderNumber, + }); + continue; + } + + await reminderNotifyService.dispatch({ + reminderId: reminder.reminderId, + orderId: reminder.orderUid, + correlationId, + statusCode: reminder.statusCode, + eventCode: reminderEventCode, + }); + + // todo update reminder status + + // todo insert next reminder all base on original dispatch if have next series + + // todo mark reminder on failed + } + + commons.logInfo(name, "Reminder dispatch completed", { + correlationId, + processedCount: reminders.length, + }); + } catch (error) { + commons.logError(name, "Reminder dispatch failed", { correlationId, error }); + throw error; + } +}; + +export const handler = lambdaHandler; diff --git a/lambdas/src/reminder-dispatch-lambda/init.ts b/lambdas/src/reminder-dispatch-lambda/init.ts new file mode 100644 index 00000000..14689898 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/init.ts @@ -0,0 +1,50 @@ +import { PostgresDbClient } from "../lib/db/db-client"; +import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; +import { OrderDbClient } from "../lib/db/order-db-client"; +import { OrderStatusService } from "../lib/db/order-status-db"; +import { OrderStatusReminderDbClient } from "../lib/db/order-status-reminder-db-client"; +import { PatientDbClient } from "../lib/db/patient-db-client"; +import { ReminderNotifyService } from "../lib/notify/services/reminder-notify-service"; +import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { AWSSQSClient } from "../lib/sqs/sqs-client"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; + +export interface Environment { + reminderNotifyService: ReminderNotifyService; + orderStatusReminderDbClient: OrderStatusReminderDbClient; +} + +export function buildEnvironment(): Environment { + const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "eu-west-2"; + const notifyMessagesQueueUrl = retrieveMandatoryEnvVariable("NOTIFY_MESSAGES_QUEUE_URL"); + const homeTestBaseUrl = retrieveMandatoryEnvVariable("HOME_TEST_BASE_URL"); + + const secretsClient = new AwsSecretsClient(awsRegion); + const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); + const orderStatusDb = new OrderStatusService(dbClient); + const orderStatusReminderDbClient = new OrderStatusReminderDbClient(dbClient); + const patientDbClient = new PatientDbClient(dbClient); + const orderDbClient = new OrderDbClient(dbClient); + const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); + const sqsClient = new AWSSQSClient(); + const reminderNotifyService = new ReminderNotifyService({ + builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, + orderStatusService: orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessagesQueueUrl, + }); + + return { + reminderNotifyService, + orderStatusReminderDbClient, + }; +} + +let _env: Environment | undefined; + +export function init(): Environment { + _env ??= buildEnvironment(); + return _env; +} From 9243b1499ed85f4b716c77eede56575cc8322c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 14 Apr 2026 11:53:56 +0200 Subject: [PATCH 2/7] refactoring message builders --- .../base-notify-message-builder.ts | 8 +- .../order-dispatched-message-builder.test.ts | 53 ---------- .../order-received-message-builder.test.ts | 49 --------- ...r-result-available-message-builder.test.ts | 44 -------- .../order-dispatched-message-builder.ts | 17 ++- .../order-received-message-builder.ts | 17 ++- .../order-result-available-message-builder.ts | 15 +-- .../order-status-message-builders.test.ts | 100 ++++++++++++++++++ .../order-status-notify-message-builder.ts | 10 ++ ...ispatched-reminder-message-builder.test.ts | 10 +- .../dispatched-reminder-message-builder.ts | 8 +- .../order-status-notify-service.test.ts | 26 +---- .../services/order-status-notify-service.ts | 51 ++++----- .../services/reminder-notify-service.test.ts | 20 +--- .../services/reminder-notify-service.ts | 30 ++++-- lambdas/src/order-result-lambda/init.test.ts | 14 +-- lambdas/src/order-result-lambda/init.ts | 13 ++- lambdas/src/order-status-lambda/init.test.ts | 27 ++--- lambdas/src/order-status-lambda/init.ts | 13 ++- lambdas/src/reminder-dispatch-lambda/init.ts | 11 +- 20 files changed, 243 insertions(+), 293 deletions(-) delete mode 100644 lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts delete mode 100644 lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts delete mode 100644 lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts rename lambdas/src/lib/notify/message-builders/{ => order-status}/order-dispatched-message-builder.ts (67%) rename lambdas/src/lib/notify/message-builders/{ => order-status}/order-received-message-builder.ts (66%) rename lambdas/src/lib/notify/message-builders/{ => order-status}/order-result-available-message-builder.ts (68%) create mode 100644 lambdas/src/lib/notify/message-builders/order-status/order-status-message-builders.test.ts create mode 100644 lambdas/src/lib/notify/message-builders/order-status/order-status-notify-message-builder.ts rename lambdas/src/lib/notify/message-builders/{ => reminder}/dispatched-reminder-message-builder.test.ts (85%) rename lambdas/src/lib/notify/message-builders/{ => reminder}/dispatched-reminder-message-builder.ts (82%) diff --git a/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts index 281fb13a..92500012 100644 --- a/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts @@ -4,19 +4,25 @@ import type { OrderDbClient } from "../../db/order-db-client"; import type { PatientDbClient } from "../../db/patient-db-client"; import type { NotifyMessage, NotifyRecipient } from "../../types/notify-message"; +export interface NotifyMessageBuilder { + build(input: TInput): Promise; +} + export interface NotifyMessageBuilderDependencies { patientDbClient: PatientDbClient; orderDbClient: OrderDbClient; homeTestBaseUrl: string; } -export abstract class BaseNotifyMessageBuilder { +export abstract class BaseNotifyMessageBuilder implements NotifyMessageBuilder { private readonly normalizedHomeTestBaseUrl: string; constructor(protected readonly deps: NotifyMessageBuilderDependencies) { this.normalizedHomeTestBaseUrl = deps.homeTestBaseUrl.replaceAll(/\/$/g, ""); } + abstract build(input: TInput): Promise; + protected async getRecipient(patientId: string): Promise { const patient = await this.deps.patientDbClient.get(patientId); return { nhsNumber: patient.nhsNumber, dateOfBirth: patient.birthDate }; diff --git a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts deleted file mode 100644 index 00e8d8d4..00000000 --- a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { OrderDbClient } from "../../db/order-db-client"; -import type { OrderStatusService } from "../../db/order-status-db"; -import { OrderStatusCodes } from "../../db/order-status-db"; -import type { Patient, PatientDbClient } from "../../db/patient-db-client"; -import { NotifyEventCode } from "../../types/notify-message"; -import { OrderDispatchedMessageBuilder } from "./order-dispatched-message-builder"; - -describe("OrderDispatchedMessageBuilder", () => { - const mockGetPatient = jest.fn, [string]>(); - const mockGetOrderReferenceNumber = jest.fn, [string]>(); - const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); - - const deps = { - patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, - orderDbClient: { - getOrderReferenceNumber: mockGetOrderReferenceNumber, - } as Pick as OrderDbClient, - homeTestBaseUrl: "https://hometest.example.nhs.uk/", - }; - - const orderStatusService = { - getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, - } as Pick as OrderStatusService; - - beforeEach(() => { - jest.clearAllMocks(); - mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - }); - - it("builds dispatched notify message", async () => { - const builder = new OrderDispatchedMessageBuilder(deps, orderStatusService); - - const result = await builder.build({ - patientId: "patient-1", - orderId: "order-1", - correlationId: "corr-1", - }); - - expect(result.eventCode).toBe(NotifyEventCode.OrderDispatched); - expect(result.correlationId).toBe("corr-1"); - expect(result.personalisation).toEqual({ - dispatchedDate: "6 August 2026", - orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-1/tracking", - referenceNumber: "100001", - }); - expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( - "order-1", - OrderStatusCodes.DISPATCHED, - ); - }); -}); diff --git a/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts deleted file mode 100644 index 6deebc1e..00000000 --- a/lambdas/src/lib/notify/message-builders/order-received-message-builder.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { OrderDbClient } from "../../db/order-db-client"; -import type { OrderStatusService } from "../../db/order-status-db"; -import { OrderStatusCodes } from "../../db/order-status-db"; -import type { Patient, PatientDbClient } from "../../db/patient-db-client"; -import { NotifyEventCode } from "../../types/notify-message"; -import { OrderReceivedMessageBuilder } from "./order-received-message-builder"; - -describe("OrderReceivedMessageBuilder", () => { - const mockGetPatient = jest.fn, [string]>(); - const mockGetOrderReferenceNumber = jest.fn, [string]>(); - const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); - - const deps = { - patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, - orderDbClient: { - getOrderReferenceNumber: mockGetOrderReferenceNumber, - } as Pick as OrderDbClient, - homeTestBaseUrl: "https://hometest.example.nhs.uk/", - }; - - const orderStatusService = { - getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, - } as Pick as OrderStatusService; - - beforeEach(() => { - jest.clearAllMocks(); - mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); - }); - - it("builds received notify message", async () => { - const builder = new OrderReceivedMessageBuilder(deps, orderStatusService); - - const result = await builder.build({ - patientId: "patient-1", - orderId: "order-2", - correlationId: "corr-2", - }); - - expect(result.eventCode).toBe(NotifyEventCode.OrderReceived); - expect(result.personalisation).toEqual({ - receivedDate: "6 August 2026", - orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-2/tracking", - referenceNumber: "100001", - }); - expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith("order-2", OrderStatusCodes.RECEIVED); - }); -}); diff --git a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts deleted file mode 100644 index 96c7d7e6..00000000 --- a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { OrderDbClient } from "../../db/order-db-client"; -import type { Patient, PatientDbClient } from "../../db/patient-db-client"; -import { NotifyEventCode } from "../../types/notify-message"; -import { OrderResultAvailableMessageBuilder } from "./order-result-available-message-builder"; - -describe("OrderResultAvailableMessageBuilder", () => { - const mockGetPatient = jest.fn, [string]>(); - const mockGetOrderReferenceNumber = jest.fn, [string]>(); - const mockGetOrderCreatedAt = jest.fn, [string]>(); - - const deps = { - patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, - orderDbClient: { - getOrderReferenceNumber: mockGetOrderReferenceNumber, - getOrderCreatedAt: mockGetOrderCreatedAt, - } as Pick as OrderDbClient, - homeTestBaseUrl: "https://hometest.example.nhs.uk/", - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); - mockGetOrderReferenceNumber.mockResolvedValue("100001"); - mockGetOrderCreatedAt.mockResolvedValue("2026-08-05T10:00:00Z"); - }); - - it("builds result available notify message", async () => { - const builder = new OrderResultAvailableMessageBuilder(deps); - - const result = await builder.build({ - patientId: "patient-2", - orderId: "order-3", - correlationId: "corr-3", - }); - - expect(result.eventCode).toBe(NotifyEventCode.ResultReady); - expect(result.personalisation).toEqual({ - orderedDate: "5 August 2026", - resultLinkUrl: "https://hometest.example.nhs.uk/orders/order-3/results", - referenceNumber: "100001", - }); - expect(mockGetOrderCreatedAt).toHaveBeenCalledWith("order-3"); - }); -}); diff --git a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-status/order-dispatched-message-builder.ts similarity index 67% rename from lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts rename to lambdas/src/lib/notify/message-builders/order-status/order-dispatched-message-builder.ts index 6447c5ae..5da3fc7d 100644 --- a/lambdas/src/lib/notify/message-builders/order-dispatched-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/order-status/order-dispatched-message-builder.ts @@ -1,17 +1,12 @@ -import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; -import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { OrderStatusCodes, OrderStatusService } from "../../../db/order-status-db"; +import { NotifyEventCode, type NotifyMessage } from "../../../types/notify-message"; import { BaseNotifyMessageBuilder, type NotifyMessageBuilderDependencies, -} from "./base-notify-message-builder"; +} from "../base-notify-message-builder"; +import { type OrderStatusNotifyMessageBuilderInput } from "./order-status-notify-message-builder"; -export interface OrderDispatchedMessageBuilderInput { - patientId: string; - orderId: string; - correlationId: string; -} - -export class OrderDispatchedMessageBuilder extends BaseNotifyMessageBuilder { +export class OrderDispatchedMessageBuilder extends BaseNotifyMessageBuilder { constructor( deps: NotifyMessageBuilderDependencies, private readonly orderStatusService: OrderStatusService, @@ -19,7 +14,7 @@ export class OrderDispatchedMessageBuilder extends BaseNotifyMessageBuilder { super(deps); } - async build(input: OrderDispatchedMessageBuilderInput): Promise { + async build(input: OrderStatusNotifyMessageBuilderInput): Promise { const { patientId, orderId, correlationId } = input; const [recipient, referenceNumber, dispatchedAt] = await Promise.all([ diff --git a/lambdas/src/lib/notify/message-builders/order-received-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-status/order-received-message-builder.ts similarity index 66% rename from lambdas/src/lib/notify/message-builders/order-received-message-builder.ts rename to lambdas/src/lib/notify/message-builders/order-status/order-received-message-builder.ts index f22ea6a0..dd93cac0 100644 --- a/lambdas/src/lib/notify/message-builders/order-received-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/order-status/order-received-message-builder.ts @@ -1,17 +1,12 @@ -import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; -import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { OrderStatusCodes, OrderStatusService } from "../../../db/order-status-db"; +import { NotifyEventCode, type NotifyMessage } from "../../../types/notify-message"; import { BaseNotifyMessageBuilder, type NotifyMessageBuilderDependencies, -} from "./base-notify-message-builder"; +} from "../base-notify-message-builder"; +import { type OrderStatusNotifyMessageBuilderInput } from "./order-status-notify-message-builder"; -export interface OrderReceivedMessageBuilderInput { - patientId: string; - orderId: string; - correlationId: string; -} - -export class OrderReceivedMessageBuilder extends BaseNotifyMessageBuilder { +export class OrderReceivedMessageBuilder extends BaseNotifyMessageBuilder { constructor( deps: NotifyMessageBuilderDependencies, private readonly orderStatusService: OrderStatusService, @@ -19,7 +14,7 @@ export class OrderReceivedMessageBuilder extends BaseNotifyMessageBuilder { super(deps); } - async build(input: OrderReceivedMessageBuilderInput): Promise { + async build(input: OrderStatusNotifyMessageBuilderInput): Promise { const { patientId, orderId, correlationId } = input; const [recipient, referenceNumber, receivedAt] = await Promise.all([ diff --git a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-status/order-result-available-message-builder.ts similarity index 68% rename from lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts rename to lambdas/src/lib/notify/message-builders/order-status/order-result-available-message-builder.ts index ebd3b2b1..9a20bb7a 100644 --- a/lambdas/src/lib/notify/message-builders/order-result-available-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/order-status/order-result-available-message-builder.ts @@ -1,21 +1,16 @@ -import { NotifyEventCode, type NotifyMessage } from "../../types/notify-message"; +import { NotifyEventCode, type NotifyMessage } from "../../../types/notify-message"; import { BaseNotifyMessageBuilder, type NotifyMessageBuilderDependencies, -} from "./base-notify-message-builder"; +} from "../base-notify-message-builder"; +import { type OrderStatusNotifyMessageBuilderInput } from "./order-status-notify-message-builder"; -export interface OrderResultAvailableMessageBuilderInput { - patientId: string; - orderId: string; - correlationId: string; -} - -export class OrderResultAvailableMessageBuilder extends BaseNotifyMessageBuilder { +export class OrderResultAvailableMessageBuilder extends BaseNotifyMessageBuilder { constructor(deps: NotifyMessageBuilderDependencies) { super(deps); } - async build(input: OrderResultAvailableMessageBuilderInput): Promise { + async build(input: OrderStatusNotifyMessageBuilderInput): Promise { const { patientId, orderId, correlationId } = input; const [recipient, referenceNumber, orderCreatedAt] = await Promise.all([ diff --git a/lambdas/src/lib/notify/message-builders/order-status/order-status-message-builders.test.ts b/lambdas/src/lib/notify/message-builders/order-status/order-status-message-builders.test.ts new file mode 100644 index 00000000..38ea63ff --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-status/order-status-message-builders.test.ts @@ -0,0 +1,100 @@ +import type { OrderDbClient } from "../../../db/order-db-client"; +import type { OrderStatusService } from "../../../db/order-status-db"; +import { OrderStatusCodes } from "../../../db/order-status-db"; +import type { Patient, PatientDbClient } from "../../../db/patient-db-client"; +import { NotifyEventCode } from "../../../types/notify-message"; +import type { NotifyMessageBuilderDependencies } from "../base-notify-message-builder"; +import { OrderDispatchedMessageBuilder } from "./order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "./order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "./order-result-available-message-builder"; + +describe("Order status notify message builders", () => { + const mockGetPatient = jest.fn, [string]>(); + const mockGetOrderReferenceNumber = jest.fn, [string]>(); + const mockGetOrderStatusCreatedAt = jest.fn, [string, string]>(); + const mockGetOrderCreatedAt = jest.fn, [string]>(); + + const deps: NotifyMessageBuilderDependencies = { + patientDbClient: { get: mockGetPatient } as Pick as PatientDbClient, + orderDbClient: { + getOrderReferenceNumber: mockGetOrderReferenceNumber, + getOrderCreatedAt: mockGetOrderCreatedAt, + } as Pick as OrderDbClient, + homeTestBaseUrl: "https://hometest.example.nhs.uk/", + }; + + const orderStatusService = { + getOrderStatusCreatedAt: mockGetOrderStatusCreatedAt, + } as Pick as OrderStatusService; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ nhsNumber: "1234567890", birthDate: "1990-01-02" }); + mockGetOrderReferenceNumber.mockResolvedValue("100001"); + mockGetOrderStatusCreatedAt.mockResolvedValue("2026-08-06T10:00:00Z"); + mockGetOrderCreatedAt.mockResolvedValue("2026-08-05T10:00:00Z"); + }); + + describe("OrderDispatchedMessageBuilder", () => { + it("builds dispatched notify message", async () => { + const result = await new OrderDispatchedMessageBuilder(deps, orderStatusService).build({ + patientId: "patient-1", + orderId: "order-1", + correlationId: "corr-1", + }); + + expect(result.eventCode).toBe(NotifyEventCode.OrderDispatched); + expect(result.correlationId).toBe("corr-1"); + expect(result.personalisation).toEqual({ + dispatchedDate: "6 August 2026", + orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-1/tracking", + referenceNumber: "100001", + }); + expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( + "order-1", + OrderStatusCodes.DISPATCHED, + ); + }); + }); + + describe("OrderReceivedMessageBuilder", () => { + it("builds received notify message", async () => { + const result = await new OrderReceivedMessageBuilder(deps, orderStatusService).build({ + patientId: "patient-1", + orderId: "order-2", + correlationId: "corr-2", + }); + + expect(result.eventCode).toBe(NotifyEventCode.OrderReceived); + expect(result.correlationId).toBe("corr-2"); + expect(result.personalisation).toEqual({ + receivedDate: "6 August 2026", + orderLinkUrl: "https://hometest.example.nhs.uk/orders/order-2/tracking", + referenceNumber: "100001", + }); + expect(mockGetOrderStatusCreatedAt).toHaveBeenCalledWith( + "order-2", + OrderStatusCodes.RECEIVED, + ); + }); + }); + + describe("OrderResultAvailableMessageBuilder", () => { + it("builds result available notify message", async () => { + const result = await new OrderResultAvailableMessageBuilder(deps).build({ + patientId: "patient-1", + orderId: "order-3", + correlationId: "corr-3", + }); + + expect(result.eventCode).toBe(NotifyEventCode.ResultReady); + expect(result.correlationId).toBe("corr-3"); + expect(result.personalisation).toEqual({ + orderedDate: "5 August 2026", + resultLinkUrl: "https://hometest.example.nhs.uk/orders/order-3/results", + referenceNumber: "100001", + }); + expect(mockGetOrderCreatedAt).toHaveBeenCalledWith("order-3"); + }); + }); +}); diff --git a/lambdas/src/lib/notify/message-builders/order-status/order-status-notify-message-builder.ts b/lambdas/src/lib/notify/message-builders/order-status/order-status-notify-message-builder.ts new file mode 100644 index 00000000..20c4e172 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/order-status/order-status-notify-message-builder.ts @@ -0,0 +1,10 @@ +import type { NotifyMessageBuilder } from "../base-notify-message-builder"; + +export interface OrderStatusNotifyMessageBuilderInput { + patientId: string; + orderId: string; + correlationId: string; +} + +export type OrderStatusNotifyMessageBuilder = + NotifyMessageBuilder; diff --git a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts similarity index 85% rename from lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts rename to lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts index b5d0f454..769a6d6a 100644 --- a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.test.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts @@ -1,8 +1,8 @@ -import type { OrderDbClient } from "../../db/order-db-client"; -import type { OrderStatusService } from "../../db/order-status-db"; -import { OrderStatusCodes } from "../../db/order-status-db"; -import type { Patient, PatientDbClient } from "../../db/patient-db-client"; -import { NotifyEventCode } from "../../types/notify-message"; +import type { OrderDbClient } from "../../../db/order-db-client"; +import type { OrderStatusService } from "../../../db/order-status-db"; +import { OrderStatusCodes } from "../../../db/order-status-db"; +import type { Patient, PatientDbClient } from "../../../db/patient-db-client"; +import { NotifyEventCode } from "../../../types/notify-message"; import { DispatchedReminderMessageBuilder } from "./dispatched-reminder-message-builder"; describe("DispatchedReminderMessageBuilder", () => { diff --git a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts similarity index 82% rename from lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts rename to lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts index 9577f38f..e7668543 100644 --- a/lambdas/src/lib/notify/message-builders/dispatched-reminder-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts @@ -1,9 +1,9 @@ -import { OrderStatusCodes, OrderStatusService } from "../../db/order-status-db"; -import { type NotifyMessage } from "../../types/notify-message"; +import { OrderStatusCodes, OrderStatusService } from "../../../db/order-status-db"; +import { type NotifyMessage } from "../../../types/notify-message"; import { BaseNotifyMessageBuilder, type NotifyMessageBuilderDependencies, -} from "./base-notify-message-builder"; +} from "../base-notify-message-builder"; export interface DispatchedReminderMessageBuilderInput { reminderId: string; @@ -13,7 +13,7 @@ export interface DispatchedReminderMessageBuilderInput { eventCode: string; } -export class DispatchedReminderMessageBuilder extends BaseNotifyMessageBuilder { +export class DispatchedReminderMessageBuilder extends BaseNotifyMessageBuilder { constructor( deps: NotifyMessageBuilderDependencies, private readonly orderStatusService: OrderStatusService, diff --git a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts index a516beab..bd635fdb 100644 --- a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts @@ -1,15 +1,8 @@ import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; import { OrderStatusCodes, OrderStatusUpdateParams } from "../../db/order-status-db"; import { NotifyEventCode } from "../../types/notify-message"; -import { OrderDispatchedMessageBuilder } from "../message-builders/order-dispatched-message-builder"; -import { OrderReceivedMessageBuilder } from "../message-builders/order-received-message-builder"; -import { OrderResultAvailableMessageBuilder } from "../message-builders/order-result-available-message-builder"; import { OrderStatusNotifyService } from "./order-status-notify-service"; -jest.mock("../message-builders/order-dispatched-message-builder"); -jest.mock("../message-builders/order-received-message-builder"); -jest.mock("../message-builders/order-result-available-message-builder"); - describe("OrderStatusNotifyService", () => { const mockBuildOrderDispatchedNotifyMessage = jest.fn(); const mockBuildOrderReceivedNotifyMessage = jest.fn(); @@ -29,16 +22,6 @@ describe("OrderStatusNotifyService", () => { beforeEach(() => { jest.clearAllMocks(); - (OrderDispatchedMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ - build: mockBuildOrderDispatchedNotifyMessage, - })); - (OrderReceivedMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ - build: mockBuildOrderReceivedNotifyMessage, - })); - (OrderResultAvailableMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ - build: mockBuildOrderResultAvailableNotifyMessage, - })); - mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ messageReference: "123e4567-e89b-12d3-a456-426614174099", eventCode: NotifyEventCode.OrderDispatched, @@ -73,12 +56,11 @@ describe("OrderStatusNotifyService", () => { mockInsertNotificationAuditEntry.mockResolvedValue(undefined); service = new OrderStatusNotifyService({ - builderDeps: { - patientDbClient: {} as never, - orderDbClient: {} as never, - homeTestBaseUrl: "https://hometest.example.nhs.uk", + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: { build: mockBuildOrderDispatchedNotifyMessage }, + [OrderStatusCodes.RECEIVED]: { build: mockBuildOrderReceivedNotifyMessage }, + [OrderStatusCodes.COMPLETE]: { build: mockBuildOrderResultAvailableNotifyMessage }, }, - orderStatusService: {} as never, notificationAuditDbClient: { insertNotificationAuditEntry: mockInsertNotificationAuditEntry, } as never, diff --git a/lambdas/src/lib/notify/services/order-status-notify-service.ts b/lambdas/src/lib/notify/services/order-status-notify-service.ts index 20a22c64..22683217 100644 --- a/lambdas/src/lib/notify/services/order-status-notify-service.ts +++ b/lambdas/src/lib/notify/services/order-status-notify-service.ts @@ -1,18 +1,12 @@ +import { type OrderStatusCode } from "../../db/order-status-db"; import { - type OrderStatusCode, - OrderStatusCodes, - OrderStatusService, -} from "../../db/order-status-db"; -import { type NotifyMessage } from "../../types/notify-message"; -import { type NotifyMessageBuilderDependencies } from "../message-builders/base-notify-message-builder"; -import { OrderDispatchedMessageBuilder } from "../message-builders/order-dispatched-message-builder"; -import { OrderReceivedMessageBuilder } from "../message-builders/order-received-message-builder"; -import { OrderResultAvailableMessageBuilder } from "../message-builders/order-result-available-message-builder"; + type OrderStatusNotifyMessageBuilder, + type OrderStatusNotifyMessageBuilderInput, +} from "../message-builders/order-status/order-status-notify-message-builder"; import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service"; export interface OrderStatusNotifyServiceDependencies extends NotifyServiceDependencies { - builderDeps: NotifyMessageBuilderDependencies; - orderStatusService: OrderStatusService; + notifyMessageBuilders: Partial>; } export interface OrderStatusNotifyInput { @@ -23,32 +17,29 @@ export interface OrderStatusNotifyInput { } export class OrderStatusNotifyService extends BaseNotifyService { - constructor(private readonly orderStatusDeps: OrderStatusNotifyServiceDependencies) { - super(orderStatusDeps); + private readonly notifyMessageBuilders: Partial< + Record + >; + + constructor(deps: OrderStatusNotifyServiceDependencies) { + super(deps); + this.notifyMessageBuilders = deps.notifyMessageBuilders; } async dispatch(input: OrderStatusNotifyInput): Promise { const { statusCode, orderId, patientId, correlationId } = input; - const { builderDeps, orderStatusService } = this.orderStatusDeps; - - const builderInput = { orderId, patientId, correlationId }; - let builder: { build: (input: typeof builderInput) => Promise }; - switch (statusCode) { - case OrderStatusCodes.DISPATCHED: - builder = new OrderDispatchedMessageBuilder(builderDeps, orderStatusService); - break; - case OrderStatusCodes.RECEIVED: - builder = new OrderReceivedMessageBuilder(builderDeps, orderStatusService); - break; - case OrderStatusCodes.COMPLETE: - builder = new OrderResultAvailableMessageBuilder(builderDeps); - break; - default: - return; + const notifyMessageBuilder = this.notifyMessageBuilders[statusCode]; + if (!notifyMessageBuilder) { + return; } - const notifyMessage = await builder.build(builderInput); + const builderInput: OrderStatusNotifyMessageBuilderInput = { + orderId, + patientId, + correlationId, + }; + const notifyMessage = await notifyMessageBuilder.build(builderInput); await this.dispatchNotification(notifyMessage, orderId); } } diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts index 7c60979d..eb1178cf 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -1,14 +1,11 @@ import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; import { OrderStatusCodes } from "../../db/order-status-db"; import { NotifyEventCode } from "../../types/notify-message"; -import { DispatchedReminderMessageBuilder } from "../message-builders/dispatched-reminder-message-builder"; import { ReminderNotifyService, type ReminderNotifyServiceDependencies, } from "./reminder-notify-service"; -jest.mock("../message-builders/dispatched-reminder-message-builder"); - describe("ReminderNotifyService", () => { const mockGetPatientIdFromOrder = jest.fn(); const mockBuildDispatchedReminderMessage = jest.fn(); @@ -23,29 +20,20 @@ describe("ReminderNotifyService", () => { const buildService = (deps?: Partial): ReminderNotifyService => new ReminderNotifyService({ - builderDeps: { - patientDbClient: {} as never, - orderDbClient: {} as never, - homeTestBaseUrl: "https://hometest.example.nhs.uk", + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: { build: mockBuildDispatchedReminderMessage }, }, - orderStatusService: { - getPatientIdFromOrder: mockGetPatientIdFromOrder, - } as never, + orderStatusService: { getPatientIdFromOrder: mockGetPatientIdFromOrder }, notificationAuditDbClient: { insertNotificationAuditEntry: mockInsertNotificationAuditEntry, } as never, - sqsClient: { - sendMessage: mockSendMessage, - }, + sqsClient: { sendMessage: mockSendMessage }, notifyMessagesQueueUrl: "https://example.queue.local/notify", ...deps, }); beforeEach(() => { jest.clearAllMocks(); - (DispatchedReminderMessageBuilder as unknown as jest.Mock).mockImplementation(() => ({ - build: mockBuildDispatchedReminderMessage, - })); mockGetPatientIdFromOrder.mockResolvedValue("patient-123"); mockBuildDispatchedReminderMessage.mockResolvedValue({ messageReference: reminderId, diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.ts b/lambdas/src/lib/notify/services/reminder-notify-service.ts index df9b4975..aee0fc97 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -4,16 +4,18 @@ import { OrderStatusCodes, OrderStatusService, } from "../../db/order-status-db"; -import { type NotifyMessageBuilderDependencies } from "../message-builders/base-notify-message-builder"; -import { DispatchedReminderMessageBuilder } from "../message-builders/dispatched-reminder-message-builder"; +import type { NotifyMessageBuilder } from "../message-builders/base-notify-message-builder"; +import type { DispatchedReminderMessageBuilderInput } from "../message-builders/reminder/dispatched-reminder-message-builder"; import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service"; const commons = new ConsoleCommons(); const name = "reminder-notify-service"; export interface ReminderNotifyServiceDependencies extends NotifyServiceDependencies { - builderDeps: NotifyMessageBuilderDependencies; - orderStatusService: OrderStatusService; + notifyMessageBuilders: Partial< + Record> + >; + orderStatusService: Pick; } export interface ReminderNotifyInput { @@ -25,19 +27,26 @@ export interface ReminderNotifyInput { } export class ReminderNotifyService extends BaseNotifyService { - constructor(private readonly reminderDeps: ReminderNotifyServiceDependencies) { - super(reminderDeps); + private readonly notifyMessageBuilders: Partial< + Record> + >; + private readonly orderStatusService: Pick; + + constructor(deps: ReminderNotifyServiceDependencies) { + super(deps); + this.notifyMessageBuilders = deps.notifyMessageBuilders; + this.orderStatusService = deps.orderStatusService; } async dispatch(input: ReminderNotifyInput): Promise { const { reminderId, orderId, correlationId, statusCode, eventCode } = input; - const { builderDeps, orderStatusService } = this.reminderDeps; - if (statusCode !== OrderStatusCodes.DISPATCHED) { + const notifyMessageBuilder = this.notifyMessageBuilders[statusCode]; + if (!notifyMessageBuilder) { return; } - const patientId = await orderStatusService.getPatientIdFromOrder(orderId); + const patientId = await this.orderStatusService.getPatientIdFromOrder(orderId); if (!patientId) { commons.logError(name, "Patient not found for reminder notification", { @@ -47,8 +56,7 @@ export class ReminderNotifyService extends BaseNotifyService { return; } - const builder = new DispatchedReminderMessageBuilder(builderDeps, orderStatusService); - const notifyMessage = await builder.build({ + const notifyMessage = await notifyMessageBuilder.build({ reminderId, patientId, orderId, diff --git a/lambdas/src/order-result-lambda/init.test.ts b/lambdas/src/order-result-lambda/init.test.ts index 49b84aa0..c9d477a7 100644 --- a/lambdas/src/order-result-lambda/init.test.ts +++ b/lambdas/src/order-result-lambda/init.test.ts @@ -6,6 +6,9 @@ import { OrderService } from "../lib/db/order-db"; import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; +import { OrderDispatchedMessageBuilder } from "../lib/notify/message-builders/order-status/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../lib/notify/message-builders/order-status/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../lib/notify/message-builders/order-status/order-result-available-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; @@ -118,12 +121,11 @@ describe("order-result-lambda init", () => { expect(OrderStatusNotifyService).toHaveBeenCalledWith( expect.objectContaining({ - builderDeps: { - patientDbClient: expect.any(PatientDbClient), - orderDbClient: expect.any(OrderDbClient), - homeTestBaseUrl: "https://hometest.example.nhs.uk", - }, - orderStatusService: expect.any(OrderStatusService), + notifyMessageBuilders: expect.objectContaining({ + DISPATCHED: expect.any(OrderDispatchedMessageBuilder), + RECEIVED: expect.any(OrderReceivedMessageBuilder), + COMPLETE: expect.any(OrderResultAvailableMessageBuilder), + }), notifyMessagesQueueUrl: "https://example.queue.local/notify", notificationAuditDbClient: expect.any(NotificationAuditDbClient), sqsClient: expect.any(AWSSQSClient), diff --git a/lambdas/src/order-result-lambda/init.ts b/lambdas/src/order-result-lambda/init.ts index ed3cea9d..7f804178 100644 --- a/lambdas/src/order-result-lambda/init.ts +++ b/lambdas/src/order-result-lambda/init.ts @@ -4,8 +4,11 @@ import { postgresConfigFromEnv } from "../lib/db/db-config"; import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderService } from "../lib/db/order-db"; import { OrderDbClient } from "../lib/db/order-db-client"; -import { OrderStatusService } from "../lib/db/order-status-db"; +import { OrderStatusCodes, OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; +import { OrderDispatchedMessageBuilder } from "../lib/notify/message-builders/order-status/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../lib/notify/message-builders/order-status/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../lib/notify/message-builders/order-status/order-result-available-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; @@ -31,9 +34,13 @@ export function buildEnvironment(): Environment { const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); + const builderDeps = { patientDbClient, orderDbClient, homeTestBaseUrl }; const orderStatusNotifyService = new OrderStatusNotifyService({ - builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, - orderStatusService: orderStatusDb, + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: new OrderDispatchedMessageBuilder(builderDeps, orderStatusDb), + [OrderStatusCodes.RECEIVED]: new OrderReceivedMessageBuilder(builderDeps, orderStatusDb), + [OrderStatusCodes.COMPLETE]: new OrderResultAvailableMessageBuilder(builderDeps), + }, notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl, diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 8834863b..3014b098 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -1,9 +1,11 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; -import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; +import { OrderDispatchedMessageBuilder } from "../lib/notify/message-builders/order-status/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../lib/notify/message-builders/order-status/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../lib/notify/message-builders/order-status/order-result-available-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; @@ -170,17 +172,18 @@ describe("init", () => { it("should create OrderStatusNotifyService with notification dependencies", () => { init(); - expect(OrderStatusNotifyService).toHaveBeenCalledWith({ - builderDeps: { - patientDbClient: expect.any(PatientDbClient), - orderDbClient: expect.any(OrderDbClient), - homeTestBaseUrl: "https://hometest.example.nhs.uk", - }, - orderStatusService: expect.any(OrderStatusService), - notificationAuditDbClient: expect.any(NotificationAuditDbClient), - sqsClient: expect.any(AWSSQSClient), - notifyMessagesQueueUrl: "https://example.queue.local/notify", - }); + expect(OrderStatusNotifyService).toHaveBeenCalledWith( + expect.objectContaining({ + notifyMessageBuilders: expect.objectContaining({ + DISPATCHED: expect.any(OrderDispatchedMessageBuilder), + RECEIVED: expect.any(OrderReceivedMessageBuilder), + COMPLETE: expect.any(OrderResultAvailableMessageBuilder), + }), + notificationAuditDbClient: expect.any(NotificationAuditDbClient), + sqsClient: expect.any(AWSSQSClient), + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }), + ); }); }); diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 5a9654f3..b554eda0 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -2,8 +2,11 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderDbClient } from "../lib/db/order-db-client"; -import { OrderStatusService } from "../lib/db/order-status-db"; +import { OrderStatusCodes, OrderStatusService } from "../lib/db/order-status-db"; import { PatientDbClient } from "../lib/db/patient-db-client"; +import { OrderDispatchedMessageBuilder } from "../lib/notify/message-builders/order-status/order-dispatched-message-builder"; +import { OrderReceivedMessageBuilder } from "../lib/notify/message-builders/order-status/order-received-message-builder"; +import { OrderResultAvailableMessageBuilder } from "../lib/notify/message-builders/order-status/order-result-available-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/services/order-status-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; @@ -26,9 +29,13 @@ export function buildEnvironment(): Environment { const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); + const builderDeps = { patientDbClient, orderDbClient, homeTestBaseUrl }; const orderStatusNotifyService = new OrderStatusNotifyService({ - builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, - orderStatusService: orderStatusDb, + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: new OrderDispatchedMessageBuilder(builderDeps, orderStatusDb), + [OrderStatusCodes.RECEIVED]: new OrderReceivedMessageBuilder(builderDeps, orderStatusDb), + [OrderStatusCodes.COMPLETE]: new OrderResultAvailableMessageBuilder(builderDeps), + }, notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl, diff --git a/lambdas/src/reminder-dispatch-lambda/init.ts b/lambdas/src/reminder-dispatch-lambda/init.ts index 14689898..0e2b28d6 100644 --- a/lambdas/src/reminder-dispatch-lambda/init.ts +++ b/lambdas/src/reminder-dispatch-lambda/init.ts @@ -2,9 +2,10 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderDbClient } from "../lib/db/order-db-client"; -import { OrderStatusService } from "../lib/db/order-status-db"; +import { OrderStatusCodes, OrderStatusService } from "../lib/db/order-status-db"; import { OrderStatusReminderDbClient } from "../lib/db/order-status-reminder-db-client"; import { PatientDbClient } from "../lib/db/patient-db-client"; +import { DispatchedReminderMessageBuilder } from "../lib/notify/message-builders/reminder/dispatched-reminder-message-builder"; import { ReminderNotifyService } from "../lib/notify/services/reminder-notify-service"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; @@ -28,8 +29,14 @@ export function buildEnvironment(): Environment { const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); const sqsClient = new AWSSQSClient(); + const builderDeps = { patientDbClient, orderDbClient, homeTestBaseUrl }; const reminderNotifyService = new ReminderNotifyService({ - builderDeps: { patientDbClient, orderDbClient, homeTestBaseUrl }, + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: new DispatchedReminderMessageBuilder( + builderDeps, + orderStatusDb, + ), + }, orderStatusService: orderStatusDb, notificationAuditDbClient, sqsClient, From b39cb5d291ecb760582d07532b764d9716356926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 14 Apr 2026 13:49:52 +0200 Subject: [PATCH 3/7] feat: implement OrderStatusReminderDbClient and related tests for reminder scheduling and dispatching --- .../order-status-reminder-db-client.test.ts | 175 ++++++++++++++ .../lib/db/order-status-reminder-db-client.ts | 102 ++++++-- .../notify/services/base-notify-service.ts | 8 +- .../order-status-notify-service.test.ts | 4 +- .../services/reminder-notify-service.test.ts | 18 +- .../services/reminder-notify-service.ts | 16 +- lambdas/src/order-result-lambda/index.ts | 20 +- lambdas/src/order-status-lambda/index.test.ts | 4 +- lambdas/src/order-status-lambda/index.ts | 20 +- .../reminder-dispatch-lambda/index.test.ts | 67 +++++- lambdas/src/reminder-dispatch-lambda/index.ts | 85 ++++--- .../src/reminder-dispatch-lambda/init.test.ts | 227 ++++++++++++++++++ 12 files changed, 651 insertions(+), 95 deletions(-) create mode 100644 lambdas/src/lib/db/order-status-reminder-db-client.test.ts create mode 100644 lambdas/src/reminder-dispatch-lambda/init.test.ts diff --git a/lambdas/src/lib/db/order-status-reminder-db-client.test.ts b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts new file mode 100644 index 00000000..4f819485 --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts @@ -0,0 +1,175 @@ +import { type DBClient } from "./db-client"; +import { OrderStatusCodes } from "./order-status-db"; +import { + OrderStatusReminderDbClient, + type ReminderScheduleTuple, +} from "./order-status-reminder-db-client"; + +const normalizeWhitespace = (sql: string): string => sql.replace(/\s+/g, " ").trim(); + +describe("OrderStatusReminderDbClient", () => { + let dbClient: jest.Mocked>; + let client: OrderStatusReminderDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + dbClient = { + query: jest.fn(), + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + client = new OrderStatusReminderDbClient(dbClient as DBClient); + }); + + describe("getScheduledReminders", () => { + const schedules: ReminderScheduleTuple[] = [ + { + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + intervalDays: 7, + eventCode: "dispatched-initial-reminder", + }, + { + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 2, + intervalDays: 14, + eventCode: "dispatched-second-reminder", + }, + ]; + + const triggeredAt = new Date("2026-04-01T00:00:00.000Z"); + + const expectedQuery = ` + SELECT r.reminder_id, r.order_uid, r.trigger_status, r.reminder_number, r.triggered_at + FROM order_status_reminder r + JOIN unnest($1::text[], $2::smallint[], $3::integer[]) AS s(trigger_status, reminder_number, interval_days) + ON r.trigger_status = s.trigger_status + AND r.reminder_number = s.reminder_number + WHERE r.status = 'SCHEDULED' + AND r.triggered_at + (s.interval_days * INTERVAL '1 day') <= NOW() + `; + + it("returns an empty array without querying the DB when schedules list is empty", async () => { + const result = await client.getScheduledReminders([]); + + expect(result).toEqual([]); + expect(dbClient.query).not.toHaveBeenCalled(); + }); + + it("executes the correct SQL with parallel arrays built from the schedules", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); + + await client.getScheduledReminders(schedules); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual([ + [OrderStatusCodes.DISPATCHED, OrderStatusCodes.DISPATCHED], + [1, 2], + [7, 14], + ]); + }); + + it("maps DB rows to camelCase OrderStatusReminderRecord objects", async () => { + dbClient.query.mockResolvedValue({ + rows: [ + { + reminder_id: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", + order_uid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", + trigger_status: OrderStatusCodes.DISPATCHED, + reminder_number: 1, + triggered_at: triggeredAt, + }, + ], + rowCount: 1, + }); + + const result = await client.getScheduledReminders(schedules); + + expect(result).toEqual([ + { + reminderId: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", + orderUid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + triggeredAt, + }, + ]); + }); + }); + + describe("markReminderAsQueued", () => { + const expectedQuery = ` + UPDATE order_status_reminder + SET status = 'QUEUED', sent_at = NOW() + WHERE reminder_id = $1::uuid + `; + + it("executes the correct SQL with the reminder ID", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await client.markReminderAsQueued("8d5fd7df-fd20-448f-8b22-b3f145b6e336"); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual(["8d5fd7df-fd20-448f-8b22-b3f145b6e336"]); + }); + }); + + describe("markReminderAsFailed", () => { + const expectedQuery = ` + UPDATE order_status_reminder + SET status = 'FAILED' + WHERE reminder_id = $1::uuid + `; + + it("executes the correct SQL with the reminder ID", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await client.markReminderAsFailed("2ddb4bcb-ee7f-4f89-a126-30e56fc23338"); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual(["2ddb4bcb-ee7f-4f89-a126-30e56fc23338"]); + }); + }); + + describe("scheduleReminder", () => { + const triggeredAt = new Date("2026-04-01T00:00:00.000Z"); + + const expectedQuery = ` + INSERT INTO order_status_reminder (order_uid, trigger_status, reminder_number, status, triggered_at) + VALUES ($1::uuid, $2, $3::smallint, 'SCHEDULED', $4) + `; + + it("executes the correct SQL with all parameters", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await client.scheduleReminder( + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + OrderStatusCodes.DISPATCHED, + 2, + triggeredAt, + ); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual([ + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + OrderStatusCodes.DISPATCHED, + 2, + triggeredAt, + ]); + }); + }); +}); diff --git a/lambdas/src/lib/db/order-status-reminder-db-client.ts b/lambdas/src/lib/db/order-status-reminder-db-client.ts index 8cb33033..81a4eaa2 100644 --- a/lambdas/src/lib/db/order-status-reminder-db-client.ts +++ b/lambdas/src/lib/db/order-status-reminder-db-client.ts @@ -1,31 +1,101 @@ import { type DBClient } from "./db-client"; -import { type OrderStatusCode, OrderStatusCodes } from "./order-status-db"; +import { type OrderStatusCode } from "./order-status-db"; export interface OrderStatusReminderRecord { reminderId: string; orderUid: string; - statusCode: OrderStatusCode; + triggerStatus: OrderStatusCode; reminderNumber: number; + triggeredAt: Date; +} + +export interface ReminderScheduleTuple { + triggerStatus: string; + reminderNumber: number; + intervalDays: number; + eventCode: string; } export class OrderStatusReminderDbClient { constructor(private readonly dbClient: DBClient) {} - async getPendingReminders(): Promise { - // Temporary mock data. Replace with a DB query against order_status_reminder. - return [ - { - reminderId: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", - orderUid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", - statusCode: OrderStatusCodes.DISPATCHED, - reminderNumber: 1, - }, + async getScheduledReminders( + schedules: ReminderScheduleTuple[], + ): Promise { + if (schedules.length === 0) { + return []; + } + + const triggerStatuses = schedules.map((s) => s.triggerStatus); + const reminderNumbers = schedules.map((s) => s.reminderNumber); + const intervalDays = schedules.map((s) => s.intervalDays); + + const query = ` + SELECT r.reminder_id, r.order_uid, r.trigger_status, r.reminder_number, r.triggered_at + FROM order_status_reminder r + JOIN unnest($1::text[], $2::smallint[], $3::integer[]) AS s(trigger_status, reminder_number, interval_days) + ON r.trigger_status = s.trigger_status + AND r.reminder_number = s.reminder_number + WHERE r.status = 'SCHEDULED' + AND r.triggered_at + (s.interval_days * INTERVAL '1 day') <= NOW() + `; + + const result = await this.dbClient.query< { - reminderId: "2ddb4bcb-ee7f-4f89-a126-30e56fc23338", - orderUid: "7f97f8a4-75f3-47dc-8faf-f7f9ca6ec1ac", - statusCode: OrderStatusCodes.DISPATCHED, - reminderNumber: 2, + reminder_id: string; + order_uid: string; + trigger_status: string; + reminder_number: number; + triggered_at: Date; }, - ]; + [string[], number[], number[]] + >(query, [triggerStatuses, reminderNumbers, intervalDays]); + + return result.rows.map((row) => ({ + reminderId: row.reminder_id, + orderUid: row.order_uid, + triggerStatus: row.trigger_status as OrderStatusCode, + reminderNumber: row.reminder_number, + triggeredAt: row.triggered_at, + })); + } + + async markReminderAsQueued(reminderId: string): Promise { + const query = ` + UPDATE order_status_reminder + SET status = 'QUEUED', sent_at = NOW() + WHERE reminder_id = $1::uuid + `; + + await this.dbClient.query(query, [reminderId]); + } + + async markReminderAsFailed(reminderId: string): Promise { + const query = ` + UPDATE order_status_reminder + SET status = 'FAILED' + WHERE reminder_id = $1::uuid + `; + + await this.dbClient.query(query, [reminderId]); + } + + async scheduleReminder( + orderUid: string, + triggerStatus: string, + reminderNumber: number, + triggeredAt: Date, + ): Promise { + const query = ` + INSERT INTO order_status_reminder (order_uid, trigger_status, reminder_number, status, triggered_at) + VALUES ($1::uuid, $2, $3::smallint, 'SCHEDULED', $4) + `; + + await this.dbClient.query(query, [ + orderUid, + triggerStatus, + reminderNumber, + triggeredAt, + ]); } } diff --git a/lambdas/src/lib/notify/services/base-notify-service.ts b/lambdas/src/lib/notify/services/base-notify-service.ts index d0654a9a..5b4212ab 100644 --- a/lambdas/src/lib/notify/services/base-notify-service.ts +++ b/lambdas/src/lib/notify/services/base-notify-service.ts @@ -25,11 +25,14 @@ export abstract class BaseNotifyService { const { correlationId } = notifyMessage; const { notificationAuditDbClient, sqsClient, notifyMessagesQueueUrl } = this.dependencies; + let sqsMessageId: string | undefined; + try { const sqsResult = await sqsClient.sendMessage( notifyMessagesQueueUrl, JSON.stringify(notifyMessage), ); + sqsMessageId = sqsResult.messageId; await notificationAuditDbClient.insertNotificationAuditEntry({ messageReference: notifyMessage.messageReference, @@ -42,7 +45,7 @@ export abstract class BaseNotifyService { correlationId, orderId, eventCode: notifyMessage.eventCode, - messageId: sqsResult.messageId, + sqsMessageId, messageReference: notifyMessage.messageReference, }); } catch (error) { @@ -50,8 +53,11 @@ export abstract class BaseNotifyService { correlationId, orderId, eventCode: notifyMessage.eventCode, + messageReference: notifyMessage.messageReference, + sqsMessageId, error, }); + throw error; } } } diff --git a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts index 3e6abbfd..ea2b106e 100644 --- a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts @@ -142,7 +142,7 @@ describe("OrderStatusNotifyService", () => { expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); }); - it("should swallow errors when sending the notify message fails", async () => { + it("should propagate errors when sending the notify message fails", async () => { mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); await expect( @@ -152,7 +152,7 @@ describe("OrderStatusNotifyService", () => { correlationId: statusUpdate.correlationId, statusCode: OrderStatusCodes.DISPATCHED, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("SQS unavailable"); expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); }); diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts index eb1178cf..7e4ebfa3 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -91,16 +91,18 @@ describe("ReminderNotifyService", () => { expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); }); - it("does not dispatch when patient is not found", async () => { + it("throws when patient is not found", async () => { mockGetPatientIdFromOrder.mockResolvedValueOnce(null); - await service.dispatch({ - reminderId, - orderId, - correlationId, - statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedSecondReminder, - }); + await expect( + service.dispatch({ + reminderId, + orderId, + correlationId, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: NotifyEventCode.DispatchedSecondReminder, + }), + ).rejects.toThrow(`Patient not found for orderId ${orderId}`); expect(mockBuildDispatchedReminderMessage).not.toHaveBeenCalled(); expect(mockSendMessage).not.toHaveBeenCalled(); diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.ts b/lambdas/src/lib/notify/services/reminder-notify-service.ts index aee0fc97..7ad0fb16 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -1,16 +1,8 @@ -import { ConsoleCommons } from "../../commons"; -import { - type OrderStatusCode, - OrderStatusCodes, - OrderStatusService, -} from "../../db/order-status-db"; +import { type OrderStatusCode, OrderStatusService } from "../../db/order-status-db"; import type { NotifyMessageBuilder } from "../message-builders/base-notify-message-builder"; import type { DispatchedReminderMessageBuilderInput } from "../message-builders/reminder/dispatched-reminder-message-builder"; import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service"; -const commons = new ConsoleCommons(); -const name = "reminder-notify-service"; - export interface ReminderNotifyServiceDependencies extends NotifyServiceDependencies { notifyMessageBuilders: Partial< Record> @@ -49,11 +41,7 @@ export class ReminderNotifyService extends BaseNotifyService { const patientId = await this.orderStatusService.getPatientIdFromOrder(orderId); if (!patientId) { - commons.logError(name, "Patient not found for reminder notification", { - correlationId, - orderId, - }); - return; + throw new Error(`Patient not found for orderId ${orderId}`); } const notifyMessage = await notifyMessageBuilder.build({ diff --git a/lambdas/src/order-result-lambda/index.ts b/lambdas/src/order-result-lambda/index.ts index a080999c..f5ee558d 100644 --- a/lambdas/src/order-result-lambda/index.ts +++ b/lambdas/src/order-result-lambda/index.ts @@ -116,12 +116,20 @@ export const handler = async (event: APIGatewayProxyEvent): Promise { ); }); - it("should return 500 when notification service fails", async () => { + it("should return 201 when notification service fails after a successful status update", async () => { mockNotify.mockRejectedValueOnce(new Error("Unexpected side effect error")); mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - expect(result.statusCode).toBe(500); + expect(result.statusCode).toBe(201); }); }); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index b7824f99..05b1d7e3 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -161,12 +161,20 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); - await orderStatusNotifyService.dispatch({ - orderId, - patientId: orderPatientId, - correlationId, - statusCode: statusOrderUpdateParams.statusCode, - }); + try { + await orderStatusNotifyService.dispatch({ + orderId, + patientId: orderPatientId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + }); + } catch (error) { + commons.logError(name, "Failed to dispatch order status notification", { + correlationId, + orderId, + error, + }); + } return createFhirResponse(201, validatedTask); } catch (error) { diff --git a/lambdas/src/reminder-dispatch-lambda/index.test.ts b/lambdas/src/reminder-dispatch-lambda/index.test.ts index 8656cc03..69e6483f 100644 --- a/lambdas/src/reminder-dispatch-lambda/index.test.ts +++ b/lambdas/src/reminder-dispatch-lambda/index.test.ts @@ -10,23 +10,30 @@ jest.mock("./init", () => ({ init: jest.fn(), })); +const TRIGGERED_AT = new Date("2026-04-01T00:00:00.000Z"); + const DISPATCHED_REMINDER_1: OrderStatusReminderRecord = { reminderId: "8d5fd7df-fd20-448f-8b22-b3f145b6e336", orderUid: "9f44d6e9-7829-49f1-a327-8eca95f5db32", - statusCode: OrderStatusCodes.DISPATCHED, + triggerStatus: OrderStatusCodes.DISPATCHED, reminderNumber: 1, + triggeredAt: TRIGGERED_AT, }; const DISPATCHED_REMINDER_2: OrderStatusReminderRecord = { reminderId: "2ddb4bcb-ee7f-4f89-a126-30e56fc23338", orderUid: "7f97f8a4-75f3-47dc-8faf-f7f9ca6ec1ac", - statusCode: OrderStatusCodes.DISPATCHED, + triggerStatus: OrderStatusCodes.DISPATCHED, reminderNumber: 2, + triggeredAt: TRIGGERED_AT, }; describe("reminder-dispatch-lambda", () => { const mockNotify = jest.fn, [unknown]>().mockResolvedValue(undefined); - const mockGetPendingReminders = jest.fn, []>(); + const mockGetScheduledReminders = jest.fn, [unknown]>(); + const mockMarkReminderAsQueued = jest.fn, [string]>().mockResolvedValue(undefined); + const mockMarkReminderAsFailed = jest.fn, [string]>().mockResolvedValue(undefined); + const mockScheduleReminder = jest.fn, [unknown]>().mockResolvedValue(undefined); const mockedInit = jest.mocked(init); @@ -53,14 +60,17 @@ describe("reminder-dispatch-lambda", () => { ], }); - mockGetPendingReminders.mockResolvedValue([DISPATCHED_REMINDER_1, DISPATCHED_REMINDER_2]); + mockGetScheduledReminders.mockResolvedValue([DISPATCHED_REMINDER_1, DISPATCHED_REMINDER_2]); mockedInit.mockReturnValue({ reminderNotifyService: { dispatch: mockNotify, }, orderStatusReminderDbClient: { - getPendingReminders: mockGetPendingReminders, + getScheduledReminders: mockGetScheduledReminders, + markReminderAsQueued: mockMarkReminderAsQueued, + markReminderAsFailed: mockMarkReminderAsFailed, + scheduleReminder: mockScheduleReminder, }, } as unknown as ReturnType); }); @@ -92,16 +102,57 @@ describe("reminder-dispatch-lambda", () => { }); }); + it("marks each reminder as queued after successful dispatch", async () => { + await lambdaHandler(mockEvent, {} as Context); + + expect(mockMarkReminderAsQueued).toHaveBeenCalledTimes(2); + expect(mockMarkReminderAsQueued).toHaveBeenCalledWith(DISPATCHED_REMINDER_1.reminderId); + expect(mockMarkReminderAsQueued).toHaveBeenCalledWith(DISPATCHED_REMINDER_2.reminderId); + }); + + it("schedules the next reminder when another exists in the series", async () => { + await lambdaHandler(mockEvent, {} as Context); + + expect(mockScheduleReminder).toHaveBeenCalledTimes(1); + expect(mockScheduleReminder).toHaveBeenCalledWith( + DISPATCHED_REMINDER_1.orderUid, + OrderStatusCodes.DISPATCHED, + 2, + TRIGGERED_AT, + ); + }); + + it("does not schedule next reminder for the last in the series", async () => { + mockGetScheduledReminders.mockResolvedValueOnce([DISPATCHED_REMINDER_2]); + + await lambdaHandler(mockEvent, {} as Context); + + expect(mockScheduleReminder).not.toHaveBeenCalled(); + }); + + it("marks reminder as failed and continues to next when dispatch throws", async () => { + const dispatchError = new Error("Notify service unavailable"); + mockNotify.mockRejectedValueOnce(dispatchError); + + await lambdaHandler(mockEvent, {} as Context); + + expect(mockMarkReminderAsFailed).toHaveBeenCalledTimes(1); + expect(mockMarkReminderAsFailed).toHaveBeenCalledWith(DISPATCHED_REMINDER_1.reminderId); + + expect(mockMarkReminderAsQueued).toHaveBeenCalledTimes(1); + expect(mockMarkReminderAsQueued).toHaveBeenCalledWith(DISPATCHED_REMINDER_2.reminderId); + }); + it("skips reminders whose status is not in the enabled set", async () => { process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([OrderStatusCodes.RECEIVED]); await lambdaHandler(mockEvent, {} as Context); expect(mockNotify).not.toHaveBeenCalled(); + expect(mockMarkReminderAsQueued).not.toHaveBeenCalled(); }); it("skips reminders with no matching event code in the configuration", async () => { - // Only one schedule configured — reminder 2 has no eventCode process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ [OrderStatusCodes.DISPATCHED]: [ { interval: 7, eventCode: NotifyEventCode.DispatchedInitialReminder }, @@ -117,9 +168,9 @@ describe("reminder-dispatch-lambda", () => { }); }); - it("throws and re-throws when getPendingReminders rejects", async () => { + it("throws and re-throws when getScheduledReminders rejects", async () => { const error = new Error("DB connection failed"); - mockGetPendingReminders.mockRejectedValueOnce(error); + mockGetScheduledReminders.mockRejectedValueOnce(error); await expect(lambdaHandler(mockEvent, {} as Context)).rejects.toThrow("DB connection failed"); expect(mockNotify).not.toHaveBeenCalled(); diff --git a/lambdas/src/reminder-dispatch-lambda/index.ts b/lambdas/src/reminder-dispatch-lambda/index.ts index 7bafad40..abce8f47 100644 --- a/lambdas/src/reminder-dispatch-lambda/index.ts +++ b/lambdas/src/reminder-dispatch-lambda/index.ts @@ -1,26 +1,13 @@ import { Context, EventBridgeEvent } from "aws-lambda"; import { ConsoleCommons } from "../lib/commons"; -import { type OrderStatusReminderRecord } from "../lib/db/order-status-reminder-db-client"; -import { type ReminderConfiguration, getReminderDispatchConfigFromEnv } from "./config"; +import { type ReminderScheduleTuple } from "../lib/db/order-status-reminder-db-client"; +import { getReminderDispatchConfigFromEnv } from "./config"; import { init } from "./init"; const commons = new ConsoleCommons(); const name = "reminder-dispatch-lambda"; -function getReminderEventCode( - reminder: OrderStatusReminderRecord, - reminderConfiguration: ReminderConfiguration, -): string | undefined { - const schedules = reminderConfiguration[reminder.statusCode]; - - if (!schedules || schedules.length === 0) { - return undefined; - } - - return schedules[reminder.reminderNumber - 1]?.eventCode; -} - export const lambdaHandler = async ( event: EventBridgeEvent<"ReminderDispatchEvent", unknown>, _context: Context, @@ -37,21 +24,33 @@ export const lambdaHandler = async ( }); // cleanup stale rows HOTE-1136 - // todo implement proper database method need HOTE-1125 - const reminders = await orderStatusReminderDbClient.getPendingReminders(); + const schedules: ReminderScheduleTuple[] = Object.entries(reminderConfiguration).flatMap( + ([triggerStatus, configs]) => + (configs ?? []).map((config, index) => ({ + triggerStatus, + reminderNumber: index + 1, + intervalDays: config.interval, + eventCode: config.eventCode, + })), + ); + const reminders = await orderStatusReminderDbClient.getScheduledReminders(schedules); for (const reminder of reminders) { - if (!enabledReminderStatuses.has(reminder.statusCode)) { + if (!enabledReminderStatuses.has(reminder.triggerStatus)) { commons.logInfo(name, "Reminder skipped for disabled trigger status", { correlationId, reminderId: reminder.reminderId, orderUid: reminder.orderUid, - statusCode: reminder.statusCode, + triggerStatus: reminder.triggerStatus, }); continue; } - const reminderEventCode = getReminderEventCode(reminder, reminderConfiguration); + const reminderEventCode = schedules.find( + (s) => + s.triggerStatus === reminder.triggerStatus && + s.reminderNumber === reminder.reminderNumber, + )?.eventCode; if (!reminderEventCode) { commons.logInfo(name, "No reminder event code configured", { @@ -62,19 +61,41 @@ export const lambdaHandler = async ( continue; } - await reminderNotifyService.dispatch({ - reminderId: reminder.reminderId, - orderId: reminder.orderUid, - correlationId, - statusCode: reminder.statusCode, - eventCode: reminderEventCode, - }); - - // todo update reminder status - - // todo insert next reminder all base on original dispatch if have next series + try { + await reminderNotifyService.dispatch({ + reminderId: reminder.reminderId, + orderId: reminder.orderUid, + correlationId, + statusCode: reminder.triggerStatus, + eventCode: reminderEventCode, + }); + } catch (error) { + commons.logError(name, "Failed to dispatch reminder", { + correlationId, + reminderId: reminder.reminderId, + orderUid: reminder.orderUid, + error, + }); + await orderStatusReminderDbClient.markReminderAsFailed(reminder.reminderId); + continue; + } - // todo mark reminder on failed + await orderStatusReminderDbClient.markReminderAsQueued(reminder.reminderId); + + const nextSchedule = schedules.find( + (s) => + s.triggerStatus === reminder.triggerStatus && + s.reminderNumber === reminder.reminderNumber + 1, + ); + + if (nextSchedule) { + await orderStatusReminderDbClient.scheduleReminder( + reminder.orderUid, + reminder.triggerStatus, + nextSchedule.reminderNumber, + reminder.triggeredAt, + ); + } } commons.logInfo(name, "Reminder dispatch completed", { diff --git a/lambdas/src/reminder-dispatch-lambda/init.test.ts b/lambdas/src/reminder-dispatch-lambda/init.test.ts new file mode 100644 index 00000000..b8b29f79 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/init.test.ts @@ -0,0 +1,227 @@ +import { PostgresDbClient } from "../lib/db/db-client"; +import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; +import { OrderDbClient } from "../lib/db/order-db-client"; +import { OrderStatusService } from "../lib/db/order-status-db"; +import { OrderStatusReminderDbClient } from "../lib/db/order-status-reminder-db-client"; +import { PatientDbClient } from "../lib/db/patient-db-client"; +import { DispatchedReminderMessageBuilder } from "../lib/notify/message-builders/reminder/dispatched-reminder-message-builder"; +import { ReminderNotifyService } from "../lib/notify/services/reminder-notify-service"; +import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { AWSSQSClient } from "../lib/sqs/sqs-client"; +import { testComponentCreationOrder } from "../lib/test-utils/component-integration-helpers"; +import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environment-test-helpers"; +import { buildEnvironment as init } from "./init"; + +jest.mock("../lib/db/order-status-db"); +jest.mock("../lib/db/order-status-reminder-db-client"); +jest.mock("../lib/db/patient-db-client"); +jest.mock("../lib/db/order-db-client"); +jest.mock("../lib/db/notification-audit-db-client"); +jest.mock("../lib/db/db-client"); +jest.mock("../lib/secrets/secrets-manager-client"); +jest.mock("../lib/sqs/sqs-client"); +jest.mock("../lib/db/db-config"); +jest.mock("../lib/notify/services/reminder-notify-service"); +jest.mock("../lib/notify/message-builders/reminder/dispatched-reminder-message-builder"); + +describe("init", () => { + const originalEnv = process.env; + + const mockEnvVariables = { + DB_USERNAME: "test-username", + DB_ADDRESS: "test-address", + DB_PORT: "5432", + DB_NAME: "test-database", + DB_SCHEMA: "test-schema", + DB_SECRET_NAME: "test-secret-name", + AWS_REGION: "eu-west-2", + NOTIFY_MESSAGES_QUEUE_URL: "https://example.queue.local/notify", + HOME_TEST_BASE_URL: "https://hometest.example.nhs.uk", + }; + + const mockPostgresConfig = { + user: "test-user", + host: "test-host", + port: 5432, + database: "test-db", + password: jest.fn().mockResolvedValue("test-password"), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setupEnvironment(mockEnvVariables); + + (postgresConfigFromEnv as jest.Mock).mockReturnValue(mockPostgresConfig); + }); + + afterEach(() => { + restoreEnvironment(originalEnv); + }); + + describe("successful initialization", () => { + it("should return an Environment object with all required properties", () => { + const result = init(); + + expect(result).toEqual({ + reminderNotifyService: expect.any(ReminderNotifyService), + orderStatusReminderDbClient: expect.any(OrderStatusReminderDbClient), + }); + }); + + it("should create AwsSecretsClient with AWS_REGION when set", () => { + process.env.AWS_REGION = "us-east-1"; + + init(); + + expect(AwsSecretsClient).toHaveBeenCalledWith("us-east-1"); + }); + + it("should fall back to AWS_DEFAULT_REGION when AWS_REGION is not set", () => { + delete process.env.AWS_REGION; + process.env.AWS_DEFAULT_REGION = "ap-southeast-1"; + + init(); + + expect(AwsSecretsClient).toHaveBeenCalledWith("ap-southeast-1"); + }); + + it("should default to eu-west-2 when neither AWS_REGION nor AWS_DEFAULT_REGION is set", () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + + init(); + + expect(AwsSecretsClient).toHaveBeenCalledWith("eu-west-2"); + }); + + it("should throw when NOTIFY_MESSAGES_QUEUE_URL is not set", () => { + delete process.env.NOTIFY_MESSAGES_QUEUE_URL; + + expect(() => init()).toThrow( + "Missing value for an environment variable NOTIFY_MESSAGES_QUEUE_URL", + ); + }); + + it("should throw when HOME_TEST_BASE_URL is not set", () => { + delete process.env.HOME_TEST_BASE_URL; + + expect(() => init()).toThrow("Missing value for an environment variable HOME_TEST_BASE_URL"); + }); + + it("should create PostgresDbClient with correct configuration", () => { + init(); + + expect(PostgresDbClient).toHaveBeenCalledWith(mockPostgresConfig); + }); + }); + + describe("integration of components", () => { + it("should call postgresConfigFromEnv with AwsSecretsClient instance", () => { + init(); + + expect(postgresConfigFromEnv).toHaveBeenCalledWith(expect.any(AwsSecretsClient)); + }); + + it("should create components in the correct order", () => { + testComponentCreationOrder({ + initFn: init, + components: [ + { + mock: AwsSecretsClient as jest.Mock, + times: 1, + }, + { + mock: PostgresDbClient as jest.Mock, + times: 1, + calledWith: mockPostgresConfig, + }, + { + mock: OrderStatusService as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: OrderStatusReminderDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: PatientDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: OrderDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: NotificationAuditDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: AWSSQSClient as jest.Mock, + times: 1, + }, + { + mock: ReminderNotifyService as jest.Mock, + times: 1, + }, + ], + }); + }); + + it("should create ReminderNotifyService with notification dependencies", () => { + init(); + + expect(ReminderNotifyService).toHaveBeenCalledWith( + expect.objectContaining({ + notifyMessageBuilders: { + DISPATCHED: expect.any(DispatchedReminderMessageBuilder), + }, + orderStatusService: expect.any(OrderStatusService), + notificationAuditDbClient: expect.any(NotificationAuditDbClient), + sqsClient: expect.any(AWSSQSClient), + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }), + ); + }); + }); + + describe("singleton protection", () => { + it("should only construct dependencies once no matter how many times init() is called", () => { + jest.isolateModules(() => { + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { init: singletonInit } = require("./init"); + + const env1 = singletonInit(); + const env2 = singletonInit(); + + expect(PostgresDbClient).toHaveBeenCalledTimes(1); + expect(env1).toBe(env2); + }); + }); + }); + + describe("rejection retry", () => { + it("should allow retry after buildEnvironment throws", () => { + jest.isolateModules(() => { + jest.clearAllMocks(); + (PostgresDbClient as jest.Mock).mockImplementationOnce(() => { + throw new Error("DB connection failed"); + }); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { init: singletonInit } = require("./init"); + + expect(() => singletonInit()).toThrow("DB connection failed"); + + // _env was never assigned (??= only assigns if the expression completes) + const result = singletonInit(); + expect(result).toBeTruthy(); + }); + }); + }); +}); From 6926666472358602884fe9a106daadee0c5e12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 14 Apr 2026 14:23:14 +0200 Subject: [PATCH 4/7] add reminder dispatch lambda and schedule configuration --- local-environment/docker-compose.yml | 2 +- local-environment/infra/main.tf | 50 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/local-environment/docker-compose.yml b/local-environment/docker-compose.yml index 7ee5b0c7..97ee23d9 100644 --- a/local-environment/docker-compose.yml +++ b/local-environment/docker-compose.yml @@ -9,7 +9,7 @@ services: - "127.0.0.1:4566:4566" # LocalStack Gateway - "127.0.0.1:4510-4559:4510-4559" # external services port range environment: - - SERVICES=lambda,iam,logs,s3,cloudformation,sts,apigateway,ssm,secretsmanager,sqs + - SERVICES=lambda,iam,logs,s3,cloudformation,sts,apigateway,ssm,secretsmanager,sqs,events - DEBUG=1 - LAMBDA_EXECUTOR=docker - AWS_DEFAULT_REGION=eu-west-2 diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 6dff99cf..5e396d8b 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -21,6 +21,7 @@ provider "aws" { endpoints { apigateway = "http://localhost:4566" + events = "http://localhost:4566" iam = "http://localhost:4566" lambda = "http://localhost:4566" s3 = "http://localhost:4566" @@ -534,6 +535,55 @@ module "order_status_lambda" { } } +resource "aws_lambda_function" "reminder_dispatch_lambda" { + filename = "${path.module}/../../lambdas/dist/reminder-dispatch-lambda.zip" + function_name = "${var.project_name}-reminder-dispatch-lambda" + role = aws_iam_role.lambda_role.arn + handler = "index.handler" + runtime = "nodejs24.x" + source_code_hash = filebase64sha256("${path.module}/../../lambdas/dist/reminder-dispatch-lambda.zip") + timeout = 60 + + environment { + variables = { + NODE_OPTIONS = "--enable-source-maps" + DB_USERNAME = "app_user" + DB_ADDRESS = "postgres-db" + DB_PORT = "5432" + DB_NAME = "local_hometest_db" + DB_SCHEMA = "hometest" + DB_SECRET_NAME = "postgres-db-password" + DB_SSL = "false" + NOTIFY_MESSAGES_QUEUE_URL = aws_sqs_queue.notify_messages.url + HOME_TEST_BASE_URL = "http://localhost:3000" + REMINDER_ENABLED_STATUSES = jsonencode(["DISPATCHED"]) + REMINDER_INTERVAL_CONFIG = jsonencode({ DISPATCHED = [{ interval = 7, eventCode = "DISPATCHED_INITIAL_REMINDER" }, { interval = 14, eventCode = "DISPATCHED_SECOND_REMINDER" }] }) + } + } + + depends_on = [aws_iam_role_policy_attachment.lambda_basic] +} + +resource "aws_cloudwatch_event_rule" "reminder_dispatch_schedule" { + name = "${var.project_name}-reminder-dispatch-schedule" + description = "Triggers reminder-dispatch-lambda on a scheduled interval" + schedule_expression = "rate(5 minutes)" +} + +resource "aws_cloudwatch_event_target" "reminder_dispatch_lambda_target" { + rule = aws_cloudwatch_event_rule.reminder_dispatch_schedule.name + target_id = "reminder-dispatch-lambda" + arn = aws_lambda_function.reminder_dispatch_lambda.arn +} + +resource "aws_lambda_permission" "allow_eventbridge_reminder_dispatch" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.reminder_dispatch_lambda.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.reminder_dispatch_schedule.arn +} + # API Gateway deployment resource "aws_api_gateway_deployment" "api_deployment" { rest_api_id = aws_api_gateway_rest_api.api.id From 22f4ba98b3298f5689ba84bb333e3132212884fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 14 Apr 2026 16:22:35 +0200 Subject: [PATCH 5/7] refactor reminder dispatch logic and fix after review --- ...ispatched-reminder-message-builder.test.ts | 5 +- .../services/reminder-notify-service.test.ts | 15 +- lambdas/src/lib/types/notify-message.ts | 4 +- .../dispatch-config.test.ts | 137 ++++++++++++ .../{config.ts => dispatch-config.ts} | 2 +- .../reminder-dispatch-lambda/index.test.ts | 43 ++-- lambdas/src/reminder-dispatch-lambda/index.ts | 202 +++++++++++------- .../src/reminder-dispatch-lambda/init.test.ts | 20 +- lambdas/src/reminder-dispatch-lambda/init.ts | 10 +- local-environment/infra/main.tf | 7 +- 10 files changed, 318 insertions(+), 127 deletions(-) create mode 100644 lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts rename lambdas/src/reminder-dispatch-lambda/{config.ts => dispatch-config.ts} (96%) diff --git a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts index 769a6d6a..b17cfbe3 100644 --- a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts @@ -2,7 +2,6 @@ import type { OrderDbClient } from "../../../db/order-db-client"; import type { OrderStatusService } from "../../../db/order-status-db"; import { OrderStatusCodes } from "../../../db/order-status-db"; import type { Patient, PatientDbClient } from "../../../db/patient-db-client"; -import { NotifyEventCode } from "../../../types/notify-message"; import { DispatchedReminderMessageBuilder } from "./dispatched-reminder-message-builder"; describe("DispatchedReminderMessageBuilder", () => { @@ -37,10 +36,10 @@ describe("DispatchedReminderMessageBuilder", () => { patientId: "patient-3", orderId: "order-4", correlationId: "corr-4", - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); - expect(result.eventCode).toBe(NotifyEventCode.DispatchedInitialReminder); + expect(result.eventCode).toBe("DISPATCHED_INITIAL_REMINDER"); expect(result.messageReference).toBe("rem-1"); expect(result.personalisation).toEqual({ dispatchedDate: "6 August 2026", diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts index 7e4ebfa3..9ea5284b 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -1,6 +1,5 @@ import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; import { OrderStatusCodes } from "../../db/order-status-db"; -import { NotifyEventCode } from "../../types/notify-message"; import { ReminderNotifyService, type ReminderNotifyServiceDependencies, @@ -37,7 +36,7 @@ describe("ReminderNotifyService", () => { mockGetPatientIdFromOrder.mockResolvedValue("patient-123"); mockBuildDispatchedReminderMessage.mockResolvedValue({ messageReference: reminderId, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", correlationId, recipient: { nhsNumber: "1234567890", dateOfBirth: "1990-01-02" }, personalisation: {}, @@ -54,7 +53,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); expect(mockBuildDispatchedReminderMessage).toHaveBeenCalledWith({ @@ -62,7 +61,7 @@ describe("ReminderNotifyService", () => { patientId: "patient-123", correlationId, orderId, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); expect(mockSendMessage).toHaveBeenCalledWith( "https://example.queue.local/notify", @@ -70,7 +69,7 @@ describe("ReminderNotifyService", () => { ); expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({ messageReference: reminderId, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", correlationId, status: NotificationAuditStatus.QUEUED, }); @@ -82,7 +81,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.SUBMITTED, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); expect(mockGetPatientIdFromOrder).not.toHaveBeenCalled(); @@ -100,7 +99,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedSecondReminder, + eventCode: "DISPATCHED_SECOND_REMINDER", }), ).rejects.toThrow(`Patient not found for orderId ${orderId}`); @@ -118,7 +117,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }), ).rejects.toThrow("builder failed"); diff --git a/lambdas/src/lib/types/notify-message.ts b/lambdas/src/lib/types/notify-message.ts index 6d14fd95..7f1d4495 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -1,7 +1,7 @@ export interface NotifyMessage { correlationId: string; messageReference: string; - eventCode: string; + eventCode: NotifyEventCode | string; recipient: NotifyRecipient; personalisation?: Record; } @@ -14,8 +14,6 @@ export interface NotifyRecipient { export enum NotifyEventCode { OrderConfirmed = "ORDER_CONFIRMED", OrderDispatched = "ORDER_DISPATCHED", - DispatchedInitialReminder = "DISPATCHED_INITIAL_REMINDER", - DispatchedSecondReminder = "DISPATCHED_SECOND_REMINDER", OrderReceived = "ORDER_RECEIVED", ResultReady = "RESULT_READY", } diff --git a/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts b/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts new file mode 100644 index 00000000..c7ba7151 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts @@ -0,0 +1,137 @@ +import { OrderStatusCodes } from "../lib/db/order-status-db"; +import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environment-test-helpers"; +import { getReminderDispatchConfigFromEnv } from "./dispatch-config"; + +describe("getReminderDispatchConfigFromEnv", () => { + const originalEnv = process.env; + + const baseEnv = { + REMINDER_ENABLED_STATUSES: JSON.stringify([OrderStatusCodes.DISPATCHED]), + REMINDER_INTERVAL_CONFIG: JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: "DISPATCHED_INITIAL_REMINDER" }, + { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ], + }), + }; + + beforeEach(() => { + setupEnvironment(baseEnv); + }); + + afterEach(() => { + restoreEnvironment(originalEnv); + }); + + describe("enabledReminderStatuses", () => { + it("parses a valid status list", () => { + const { enabledReminderStatuses } = getReminderDispatchConfigFromEnv(); + + expect(enabledReminderStatuses.has(OrderStatusCodes.DISPATCHED)).toBe(true); + expect(enabledReminderStatuses.size).toBe(1); + }); + + it("throws when REMINDER_ENABLED_STATUSES is missing", () => { + delete process.env.REMINDER_ENABLED_STATUSES; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "Missing value for an environment variable REMINDER_ENABLED_STATUSES", + ); + }); + + it("throws when REMINDER_ENABLED_STATUSES is not a JSON array", () => { + process.env.REMINDER_ENABLED_STATUSES = '"DISPATCHED"'; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "REMINDER_ENABLED_STATUSES must be a JSON array of order status strings", + ); + }); + + it("throws when REMINDER_ENABLED_STATUSES contains no valid statuses", () => { + process.env.REMINDER_ENABLED_STATUSES = '["UNKNOWN_STATUS"]'; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "REMINDER_ENABLED_STATUSES must contain at least one valid order status", + ); + }); + + it("silently drops unrecognised statuses, keeping valid ones", () => { + process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([ + OrderStatusCodes.DISPATCHED, + "NOT_A_REAL_STATUS", + ]); + + const { enabledReminderStatuses } = getReminderDispatchConfigFromEnv(); + + expect(enabledReminderStatuses.has(OrderStatusCodes.DISPATCHED)).toBe(true); + expect(enabledReminderStatuses.size).toBe(1); + }); + }); + + describe("reminderConfiguration", () => { + it("parses a valid configuration with two schedules", () => { + const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); + + expect(reminderConfiguration[OrderStatusCodes.DISPATCHED]).toEqual([ + { interval: 7, eventCode: "DISPATCHED_INITIAL_REMINDER" }, + { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ]); + }); + + it("throws when REMINDER_INTERVAL_CONFIG is missing", () => { + delete process.env.REMINDER_INTERVAL_CONFIG; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "Missing value for an environment variable REMINDER_INTERVAL_CONFIG", + ); + }); + + it("throws when REMINDER_INTERVAL_CONFIG is not a JSON object", () => { + process.env.REMINDER_INTERVAL_CONFIG = '"not-an-object"'; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "REMINDER_INTERVAL_CONFIG must be a JSON object", + ); + }); + + it("silently drops entries with an unrecognised status key", () => { + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + UNKNOWN_STATUS: [{ interval: 7, eventCode: "SOME_CODE" }], + }); + + const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); + + expect(Object.keys(reminderConfiguration)).toHaveLength(0); + }); + + it("silently drops schedule entries with a non-positive interval", () => { + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 0, eventCode: "DISPATCHED_INITIAL_REMINDER" }, + { interval: 7, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ], + }); + + const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); + + expect(reminderConfiguration[OrderStatusCodes.DISPATCHED]).toEqual([ + { interval: 7, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ]); + }); + + it("silently drops schedule entries with a blank eventCode", () => { + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: "" }, + { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ], + }); + + const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); + + expect(reminderConfiguration[OrderStatusCodes.DISPATCHED]).toEqual([ + { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ]); + }); + }); +}); diff --git a/lambdas/src/reminder-dispatch-lambda/config.ts b/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts similarity index 96% rename from lambdas/src/reminder-dispatch-lambda/config.ts rename to lambdas/src/reminder-dispatch-lambda/dispatch-config.ts index ec965b12..74fd66fd 100644 --- a/lambdas/src/reminder-dispatch-lambda/config.ts +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts @@ -23,7 +23,7 @@ function parseEnabledReminderStatuses(rawValue: string): ReadonlySet { beforeEach(() => { jest.clearAllMocks(); - process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([OrderStatusCodes.DISPATCHED]); - process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ - [OrderStatusCodes.DISPATCHED]: [ - { interval: 7, eventCode: NotifyEventCode.DispatchedInitialReminder }, - { interval: 14, eventCode: NotifyEventCode.DispatchedSecondReminder }, - ], - }); - mockGetScheduledReminders.mockResolvedValue([DISPATCHED_REMINDER_1, DISPATCHED_REMINDER_2]); mockedInit.mockReturnValue({ @@ -72,14 +63,16 @@ describe("reminder-dispatch-lambda", () => { markReminderAsFailed: mockMarkReminderAsFailed, scheduleReminder: mockScheduleReminder, }, + enabledReminderStatuses: new Set([OrderStatusCodes.DISPATCHED]), + reminderConfiguration: { + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: "DISPATCHED_INITIAL_REMINDER" }, + { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ], + }, } as unknown as ReturnType); }); - afterEach(() => { - delete process.env.REMINDER_ENABLED_STATUSES; - delete process.env.REMINDER_INTERVAL_CONFIG; - }); - it("calls notify for each pending reminder with the correct arguments", async () => { await lambdaHandler(mockEvent, {} as Context); @@ -90,7 +83,7 @@ describe("reminder-dispatch-lambda", () => { orderId: DISPATCHED_REMINDER_1.orderUid, correlationId: mockEvent.id, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); expect(mockNotify.mock.calls[1][0]).toMatchObject({ @@ -98,7 +91,7 @@ describe("reminder-dispatch-lambda", () => { orderId: DISPATCHED_REMINDER_2.orderUid, correlationId: mockEvent.id, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: NotifyEventCode.DispatchedSecondReminder, + eventCode: "DISPATCHED_SECOND_REMINDER", }); }); @@ -144,7 +137,10 @@ describe("reminder-dispatch-lambda", () => { }); it("skips reminders whose status is not in the enabled set", async () => { - process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([OrderStatusCodes.RECEIVED]); + mockedInit.mockReturnValueOnce({ + ...mockedInit(), + enabledReminderStatuses: new Set([OrderStatusCodes.RECEIVED]), + } as unknown as ReturnType); await lambdaHandler(mockEvent, {} as Context); @@ -153,18 +149,19 @@ describe("reminder-dispatch-lambda", () => { }); it("skips reminders with no matching event code in the configuration", async () => { - process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ - [OrderStatusCodes.DISPATCHED]: [ - { interval: 7, eventCode: NotifyEventCode.DispatchedInitialReminder }, - ], - }); + mockedInit.mockReturnValueOnce({ + ...mockedInit(), + reminderConfiguration: { + [OrderStatusCodes.DISPATCHED]: [{ interval: 7, eventCode: "DISPATCHED_INITIAL_REMINDER" }], + }, + } as unknown as ReturnType); await lambdaHandler(mockEvent, {} as Context); expect(mockNotify).toHaveBeenCalledTimes(1); expect(mockNotify.mock.calls[0][0]).toMatchObject({ reminderId: DISPATCHED_REMINDER_1.reminderId, - eventCode: NotifyEventCode.DispatchedInitialReminder, + eventCode: "DISPATCHED_INITIAL_REMINDER", }); }); diff --git a/lambdas/src/reminder-dispatch-lambda/index.ts b/lambdas/src/reminder-dispatch-lambda/index.ts index abce8f47..78a0a678 100644 --- a/lambdas/src/reminder-dispatch-lambda/index.ts +++ b/lambdas/src/reminder-dispatch-lambda/index.ts @@ -1,19 +1,129 @@ import { Context, EventBridgeEvent } from "aws-lambda"; import { ConsoleCommons } from "../lib/commons"; -import { type ReminderScheduleTuple } from "../lib/db/order-status-reminder-db-client"; -import { getReminderDispatchConfigFromEnv } from "./config"; +import { type OrderStatusCode } from "../lib/db/order-status-db"; +import { + type OrderStatusReminderDbClient, + type OrderStatusReminderRecord, + type ReminderScheduleTuple, +} from "../lib/db/order-status-reminder-db-client"; +import { type ReminderNotifyService } from "../lib/notify/services/reminder-notify-service"; +import { type ReminderConfiguration } from "./dispatch-config"; import { init } from "./init"; const commons = new ConsoleCommons(); const name = "reminder-dispatch-lambda"; +interface ProcessReminderDeps { + reminderNotifyService: ReminderNotifyService; + orderStatusReminderDbClient: OrderStatusReminderDbClient; + schedules: ReminderScheduleTuple[]; + enabledReminderStatuses: ReadonlySet; + correlationId: string; +} + +type ReminderOutcome = "dispatched" | "skipped_disabled" | "skipped_no_config" | "failed"; + +function buildSchedules(reminderConfiguration: ReminderConfiguration): ReminderScheduleTuple[] { + return Object.entries(reminderConfiguration).flatMap(([triggerStatus, configs]) => + (configs ?? []).map((config, index) => ({ + triggerStatus, + reminderNumber: index + 1, + intervalDays: config.interval, + eventCode: config.eventCode, + })), + ); +} + +async function processReminder( + reminder: OrderStatusReminderRecord, + deps: ProcessReminderDeps, +): Promise { + const { + reminderNotifyService, + orderStatusReminderDbClient, + schedules, + enabledReminderStatuses, + correlationId, + } = deps; + const logContext = { + correlationId, + reminderId: reminder.reminderId, + orderUid: reminder.orderUid, + triggerStatus: reminder.triggerStatus, + reminderNumber: reminder.reminderNumber, + }; + + if (!enabledReminderStatuses.has(reminder.triggerStatus)) { + commons.logInfo(name, "Reminder skipped for disabled trigger status", logContext); + return "skipped_disabled"; + } + + const reminderEventCode = schedules.find( + (s) => + s.triggerStatus === reminder.triggerStatus && s.reminderNumber === reminder.reminderNumber, + )?.eventCode; + + if (!reminderEventCode) { + commons.logInfo(name, "No reminder event code configured", logContext); + return "skipped_no_config"; + } + + commons.logInfo(name, "Processing reminder", logContext); + + try { + await reminderNotifyService.dispatch({ + reminderId: reminder.reminderId, + orderId: reminder.orderUid, + correlationId, + statusCode: reminder.triggerStatus, + eventCode: reminderEventCode, + }); + } catch (error) { + commons.logError(name, "Failed to dispatch reminder", { ...logContext, error }); + await orderStatusReminderDbClient.markReminderAsFailed(reminder.reminderId); + return "failed"; + } + + await orderStatusReminderDbClient.markReminderAsQueued(reminder.reminderId); + commons.logInfo(name, "Reminder dispatched successfully", { + ...logContext, + eventCode: reminderEventCode, + }); + + const nextSchedule = schedules.find( + (s) => + s.triggerStatus === reminder.triggerStatus && + s.reminderNumber === reminder.reminderNumber + 1, + ); + + if (nextSchedule) { + await orderStatusReminderDbClient.scheduleReminder( + reminder.orderUid, + reminder.triggerStatus, + nextSchedule.reminderNumber, + reminder.triggeredAt, + ); + commons.logInfo(name, "Next reminder scheduled", { + ...logContext, + reminderNumber: nextSchedule.reminderNumber, + }); + } + + return "dispatched"; +} + export const lambdaHandler = async ( event: EventBridgeEvent<"ReminderDispatchEvent", unknown>, _context: Context, ): Promise => { - const { reminderNotifyService, orderStatusReminderDbClient } = init(); - const { enabledReminderStatuses, reminderConfiguration } = getReminderDispatchConfigFromEnv(); + const { + reminderNotifyService, + orderStatusReminderDbClient, + enabledReminderStatuses, + reminderConfiguration, + } = init(); + const correlationId = event.id; try { @@ -24,83 +134,31 @@ export const lambdaHandler = async ( }); // cleanup stale rows HOTE-1136 - const schedules: ReminderScheduleTuple[] = Object.entries(reminderConfiguration).flatMap( - ([triggerStatus, configs]) => - (configs ?? []).map((config, index) => ({ - triggerStatus, - reminderNumber: index + 1, - intervalDays: config.interval, - eventCode: config.eventCode, - })), - ); + const schedules = buildSchedules(reminderConfiguration); const reminders = await orderStatusReminderDbClient.getScheduledReminders(schedules); + const outcomes: ReminderOutcome[] = []; for (const reminder of reminders) { - if (!enabledReminderStatuses.has(reminder.triggerStatus)) { - commons.logInfo(name, "Reminder skipped for disabled trigger status", { - correlationId, - reminderId: reminder.reminderId, - orderUid: reminder.orderUid, - triggerStatus: reminder.triggerStatus, - }); - continue; - } - - const reminderEventCode = schedules.find( - (s) => - s.triggerStatus === reminder.triggerStatus && - s.reminderNumber === reminder.reminderNumber, - )?.eventCode; - - if (!reminderEventCode) { - commons.logInfo(name, "No reminder event code configured", { - correlationId, - reminderId: reminder.reminderId, - reminderNumber: reminder.reminderNumber, - }); - continue; - } - - try { - await reminderNotifyService.dispatch({ - reminderId: reminder.reminderId, - orderId: reminder.orderUid, + outcomes.push( + await processReminder(reminder, { + reminderNotifyService, + orderStatusReminderDbClient, + schedules, + enabledReminderStatuses, correlationId, - statusCode: reminder.triggerStatus, - eventCode: reminderEventCode, - }); - } catch (error) { - commons.logError(name, "Failed to dispatch reminder", { - correlationId, - reminderId: reminder.reminderId, - orderUid: reminder.orderUid, - error, - }); - await orderStatusReminderDbClient.markReminderAsFailed(reminder.reminderId); - continue; - } - - await orderStatusReminderDbClient.markReminderAsQueued(reminder.reminderId); - - const nextSchedule = schedules.find( - (s) => - s.triggerStatus === reminder.triggerStatus && - s.reminderNumber === reminder.reminderNumber + 1, + }), ); - - if (nextSchedule) { - await orderStatusReminderDbClient.scheduleReminder( - reminder.orderUid, - reminder.triggerStatus, - nextSchedule.reminderNumber, - reminder.triggeredAt, - ); - } } + const countFunc = (outcome: ReminderOutcome) => outcomes.filter((o) => o === outcome).length; + commons.logInfo(name, "Reminder dispatch completed", { correlationId, - processedCount: reminders.length, + totalCount: reminders.length, + dispatchedCount: countFunc("dispatched"), + failedCount: countFunc("failed"), + skippedDisabledCount: countFunc("skipped_disabled"), + skippedNoConfigCount: countFunc("skipped_no_config"), }); } catch (error) { commons.logError(name, "Reminder dispatch failed", { correlationId, error }); diff --git a/lambdas/src/reminder-dispatch-lambda/init.test.ts b/lambdas/src/reminder-dispatch-lambda/init.test.ts index b8b29f79..b9807b69 100644 --- a/lambdas/src/reminder-dispatch-lambda/init.test.ts +++ b/lambdas/src/reminder-dispatch-lambda/init.test.ts @@ -38,6 +38,8 @@ describe("init", () => { AWS_REGION: "eu-west-2", NOTIFY_MESSAGES_QUEUE_URL: "https://example.queue.local/notify", HOME_TEST_BASE_URL: "https://hometest.example.nhs.uk", + REMINDER_ENABLED_STATUSES: '["DISPATCHED"]', + REMINDER_INTERVAL_CONFIG: '{"DISPATCHED":[{"interval":7,"eventCode":"reminder.dispatched"}]}', }; const mockPostgresConfig = { @@ -66,6 +68,8 @@ describe("init", () => { expect(result).toEqual({ reminderNotifyService: expect.any(ReminderNotifyService), orderStatusReminderDbClient: expect.any(OrderStatusReminderDbClient), + enabledReminderStatuses: expect.any(Set), + reminderConfiguration: expect.any(Object), }); }); @@ -77,22 +81,10 @@ describe("init", () => { expect(AwsSecretsClient).toHaveBeenCalledWith("us-east-1"); }); - it("should fall back to AWS_DEFAULT_REGION when AWS_REGION is not set", () => { + it("should throw when AWS_REGION is not set", () => { delete process.env.AWS_REGION; - process.env.AWS_DEFAULT_REGION = "ap-southeast-1"; - init(); - - expect(AwsSecretsClient).toHaveBeenCalledWith("ap-southeast-1"); - }); - - it("should default to eu-west-2 when neither AWS_REGION nor AWS_DEFAULT_REGION is set", () => { - delete process.env.AWS_REGION; - delete process.env.AWS_DEFAULT_REGION; - - init(); - - expect(AwsSecretsClient).toHaveBeenCalledWith("eu-west-2"); + expect(() => init()).toThrow("Missing value for an environment variable AWS_REGION"); }); it("should throw when NOTIFY_MESSAGES_QUEUE_URL is not set", () => { diff --git a/lambdas/src/reminder-dispatch-lambda/init.ts b/lambdas/src/reminder-dispatch-lambda/init.ts index 7b9d8497..d38d768c 100644 --- a/lambdas/src/reminder-dispatch-lambda/init.ts +++ b/lambdas/src/reminder-dispatch-lambda/init.ts @@ -3,6 +3,7 @@ import { postgresConfigFromEnv } from "../lib/db/db-config"; import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderDbClient } from "../lib/db/order-db-client"; import { OrderStatusCodes, OrderStatusService } from "../lib/db/order-status-db"; +import { type OrderStatusCode } from "../lib/db/order-status-db"; import { OrderStatusReminderDbClient } from "../lib/db/order-status-reminder-db-client"; import { PatientDbClient } from "../lib/db/patient-db-client"; import { DispatchedReminderMessageBuilder } from "../lib/notify/message-builders/reminder/dispatched-reminder-message-builder"; @@ -10,14 +11,17 @@ import { ReminderNotifyService } from "../lib/notify/services/reminder-notify-se import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { type ReminderConfiguration, getReminderDispatchConfigFromEnv } from "./dispatch-config"; export interface Environment { reminderNotifyService: ReminderNotifyService; orderStatusReminderDbClient: OrderStatusReminderDbClient; + enabledReminderStatuses: ReadonlySet; + reminderConfiguration: ReminderConfiguration; } export function buildEnvironment(): Environment { - const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "eu-west-2"; + const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); const notifyMessagesQueueUrl = retrieveMandatoryEnvVariable("NOTIFY_MESSAGES_QUEUE_URL"); const homeTestBaseUrl = retrieveMandatoryEnvVariable("HOME_TEST_BASE_URL"); @@ -43,9 +47,13 @@ export function buildEnvironment(): Environment { notifyMessagesQueueUrl, }); + const { enabledReminderStatuses, reminderConfiguration } = getReminderDispatchConfigFromEnv(); + return { reminderNotifyService, orderStatusReminderDbClient, + enabledReminderStatuses, + reminderConfiguration, }; } diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 5e396d8b..ffc44dd9 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -172,7 +172,10 @@ resource "aws_iam_role_policy" "lambdas_sqs_publish" { Action = [ "sqs:SendMessage" ] - Resource = aws_sqs_queue.order_placement.arn + Resource = [ + aws_sqs_queue.order_placement.arn, + aws_sqs_queue.notify_messages.arn, + ] } ] }) @@ -542,7 +545,7 @@ resource "aws_lambda_function" "reminder_dispatch_lambda" { handler = "index.handler" runtime = "nodejs24.x" source_code_hash = filebase64sha256("${path.module}/../../lambdas/dist/reminder-dispatch-lambda.zip") - timeout = 60 + timeout = 180 environment { variables = { From 57c745d83a099050d970ffebf7deb8093c1a5dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Wed, 15 Apr 2026 15:48:53 +0200 Subject: [PATCH 6/7] update eventCode to use NotifyEventCode enum across notification and reminder services, added zod parsing for env config --- .../lib/db/notification-audit-db-client.ts | 3 +- .../order-status-reminder-db-client.test.ts | 5 +- .../lib/db/order-status-reminder-db-client.ts | 3 +- .../base-notify-message-builder.ts | 4 +- ...ispatched-reminder-message-builder.test.ts | 5 +- .../dispatched-reminder-message-builder.ts | 4 +- .../services/reminder-notify-service.test.ts | 11 +- .../services/reminder-notify-service.ts | 3 +- lambdas/src/lib/types/notify-message.ts | 5 +- .../dispatch-config.test.ts | 37 +++--- .../dispatch-config.ts | 106 +++++++----------- .../src/reminder-dispatch-lambda/init.test.ts | 3 +- 12 files changed, 83 insertions(+), 106 deletions(-) diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts index 28c186b0..7822d41f 100644 --- a/lambdas/src/lib/db/notification-audit-db-client.ts +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; export enum NotificationAuditStatus { @@ -8,7 +9,7 @@ export enum NotificationAuditStatus { export interface NotificationAuditEntryParams { messageReference: string; - eventCode: string; + eventCode: NotifyEventCode; correlationId: string; status: NotificationAuditStatus; notifyMessageId?: string | null; diff --git a/lambdas/src/lib/db/order-status-reminder-db-client.test.ts b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts index 4f819485..65ade3bf 100644 --- a/lambdas/src/lib/db/order-status-reminder-db-client.test.ts +++ b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; import { OrderStatusCodes } from "./order-status-db"; import { @@ -29,13 +30,13 @@ describe("OrderStatusReminderDbClient", () => { triggerStatus: OrderStatusCodes.DISPATCHED, reminderNumber: 1, intervalDays: 7, - eventCode: "dispatched-initial-reminder", + eventCode: NotifyEventCode.DispatchedInitialReminder, }, { triggerStatus: OrderStatusCodes.DISPATCHED, reminderNumber: 2, intervalDays: 14, - eventCode: "dispatched-second-reminder", + eventCode: NotifyEventCode.DispatchedSecondReminder, }, ]; diff --git a/lambdas/src/lib/db/order-status-reminder-db-client.ts b/lambdas/src/lib/db/order-status-reminder-db-client.ts index 81a4eaa2..017aa938 100644 --- a/lambdas/src/lib/db/order-status-reminder-db-client.ts +++ b/lambdas/src/lib/db/order-status-reminder-db-client.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; import { type OrderStatusCode } from "./order-status-db"; @@ -13,7 +14,7 @@ export interface ReminderScheduleTuple { triggerStatus: string; reminderNumber: number; intervalDays: number; - eventCode: string; + eventCode: NotifyEventCode; } export class OrderStatusReminderDbClient { diff --git a/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts index 92500012..175987db 100644 --- a/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/base-notify-message-builder.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid"; import type { OrderDbClient } from "../../db/order-db-client"; import type { PatientDbClient } from "../../db/patient-db-client"; -import type { NotifyMessage, NotifyRecipient } from "../../types/notify-message"; +import type { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../../types/notify-message"; export interface NotifyMessageBuilder { build(input: TInput): Promise; @@ -42,7 +42,7 @@ export abstract class BaseNotifyMessageBuilder implements NotifyMessageB protected buildMessage(params: { correlationId: string; - eventCode: string; + eventCode: NotifyEventCode; recipient: NotifyRecipient; personalisation: Record; messageReference?: string; diff --git a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts index b17cfbe3..8bb2ea45 100644 --- a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../../../../lib/types/notify-message"; import type { OrderDbClient } from "../../../db/order-db-client"; import type { OrderStatusService } from "../../../db/order-status-db"; import { OrderStatusCodes } from "../../../db/order-status-db"; @@ -36,10 +37,10 @@ describe("DispatchedReminderMessageBuilder", () => { patientId: "patient-3", orderId: "order-4", correlationId: "corr-4", - eventCode: "DISPATCHED_INITIAL_REMINDER", + eventCode: NotifyEventCode.DispatchedInitialReminder, }); - expect(result.eventCode).toBe("DISPATCHED_INITIAL_REMINDER"); + expect(result.eventCode).toBe(NotifyEventCode.DispatchedInitialReminder); expect(result.messageReference).toBe("rem-1"); expect(result.personalisation).toEqual({ dispatchedDate: "6 August 2026", diff --git a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts index e7668543..25d0df85 100644 --- a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts @@ -1,5 +1,5 @@ import { OrderStatusCodes, OrderStatusService } from "../../../db/order-status-db"; -import { type NotifyMessage } from "../../../types/notify-message"; +import { NotifyEventCode, type NotifyMessage } from "../../../types/notify-message"; import { BaseNotifyMessageBuilder, type NotifyMessageBuilderDependencies, @@ -10,7 +10,7 @@ export interface DispatchedReminderMessageBuilderInput { patientId: string; orderId: string; correlationId: string; - eventCode: string; + eventCode: NotifyEventCode; } export class DispatchedReminderMessageBuilder extends BaseNotifyMessageBuilder { diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts index 9ea5284b..e6641976 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../../../lib/types/notify-message"; import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; import { OrderStatusCodes } from "../../db/order-status-db"; import { @@ -53,7 +54,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: "DISPATCHED_INITIAL_REMINDER", + eventCode: NotifyEventCode.DispatchedInitialReminder, }); expect(mockBuildDispatchedReminderMessage).toHaveBeenCalledWith({ @@ -61,7 +62,7 @@ describe("ReminderNotifyService", () => { patientId: "patient-123", correlationId, orderId, - eventCode: "DISPATCHED_INITIAL_REMINDER", + eventCode: NotifyEventCode.DispatchedInitialReminder, }); expect(mockSendMessage).toHaveBeenCalledWith( "https://example.queue.local/notify", @@ -81,7 +82,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.SUBMITTED, - eventCode: "DISPATCHED_INITIAL_REMINDER", + eventCode: NotifyEventCode.DispatchedInitialReminder, }); expect(mockGetPatientIdFromOrder).not.toHaveBeenCalled(); @@ -99,7 +100,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: "DISPATCHED_SECOND_REMINDER", + eventCode: NotifyEventCode.DispatchedSecondReminder, }), ).rejects.toThrow(`Patient not found for orderId ${orderId}`); @@ -117,7 +118,7 @@ describe("ReminderNotifyService", () => { orderId, correlationId, statusCode: OrderStatusCodes.DISPATCHED, - eventCode: "DISPATCHED_INITIAL_REMINDER", + eventCode: NotifyEventCode.DispatchedInitialReminder, }), ).rejects.toThrow("builder failed"); diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.ts b/lambdas/src/lib/notify/services/reminder-notify-service.ts index 7ad0fb16..307034ea 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -1,3 +1,4 @@ +import { NotifyEventCode } from "../../../lib/types/notify-message"; import { type OrderStatusCode, OrderStatusService } from "../../db/order-status-db"; import type { NotifyMessageBuilder } from "../message-builders/base-notify-message-builder"; import type { DispatchedReminderMessageBuilderInput } from "../message-builders/reminder/dispatched-reminder-message-builder"; @@ -15,7 +16,7 @@ export interface ReminderNotifyInput { orderId: string; correlationId: string; statusCode: OrderStatusCode; - eventCode: string; + eventCode: NotifyEventCode; } export class ReminderNotifyService extends BaseNotifyService { diff --git a/lambdas/src/lib/types/notify-message.ts b/lambdas/src/lib/types/notify-message.ts index 7f1d4495..46068f20 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -1,7 +1,7 @@ export interface NotifyMessage { correlationId: string; messageReference: string; - eventCode: NotifyEventCode | string; + eventCode: NotifyEventCode; recipient: NotifyRecipient; personalisation?: Record; } @@ -16,4 +16,7 @@ export enum NotifyEventCode { OrderDispatched = "ORDER_DISPATCHED", OrderReceived = "ORDER_RECEIVED", ResultReady = "RESULT_READY", + DispatchedInitialReminder = "DISPATCHED_INITIAL_REMINDER", + DispatchedSecondReminder = "DISPATCHED_SECOND_REMINDER", + DispatchedThirdReminder = "DISPATCHED_THIRD_REMINDER", } diff --git a/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts b/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts index c7ba7151..8c0ef165 100644 --- a/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts @@ -47,24 +47,21 @@ describe("getReminderDispatchConfigFromEnv", () => { ); }); - it("throws when REMINDER_ENABLED_STATUSES contains no valid statuses", () => { - process.env.REMINDER_ENABLED_STATUSES = '["UNKNOWN_STATUS"]'; + it("throws when REMINDER_ENABLED_STATUSES is an empty array", () => { + process.env.REMINDER_ENABLED_STATUSES = "[]"; expect(() => getReminderDispatchConfigFromEnv()).toThrow( "REMINDER_ENABLED_STATUSES must contain at least one valid order status", ); }); - it("silently drops unrecognised statuses, keeping valid ones", () => { + it("throws when REMINDER_ENABLED_STATUSES contains an unrecognised status", () => { process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([ OrderStatusCodes.DISPATCHED, "NOT_A_REAL_STATUS", ]); - const { enabledReminderStatuses } = getReminderDispatchConfigFromEnv(); - - expect(enabledReminderStatuses.has(OrderStatusCodes.DISPATCHED)).toBe(true); - expect(enabledReminderStatuses.size).toBe(1); + expect(() => getReminderDispatchConfigFromEnv()).toThrow("is not a valid order status code"); }); }); @@ -94,17 +91,15 @@ describe("getReminderDispatchConfigFromEnv", () => { ); }); - it("silently drops entries with an unrecognised status key", () => { + it("throws when REMINDER_INTERVAL_CONFIG contains an unrecognised status key", () => { process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ UNKNOWN_STATUS: [{ interval: 7, eventCode: "SOME_CODE" }], }); - const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); - - expect(Object.keys(reminderConfiguration)).toHaveLength(0); + expect(() => getReminderDispatchConfigFromEnv()).toThrow("Invalid key in record"); }); - it("silently drops schedule entries with a non-positive interval", () => { + it("throws when a schedule entry has a non-positive interval", () => { process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ [OrderStatusCodes.DISPATCHED]: [ { interval: 0, eventCode: "DISPATCHED_INITIAL_REMINDER" }, @@ -112,14 +107,12 @@ describe("getReminderDispatchConfigFromEnv", () => { ], }); - const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); - - expect(reminderConfiguration[OrderStatusCodes.DISPATCHED]).toEqual([ - { interval: 7, eventCode: "DISPATCHED_SECOND_REMINDER" }, - ]); + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "interval must be a positive number, received: 0", + ); }); - it("silently drops schedule entries with a blank eventCode", () => { + it("throws when a schedule entry has an invalid eventCode", () => { process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ [OrderStatusCodes.DISPATCHED]: [ { interval: 7, eventCode: "" }, @@ -127,11 +120,9 @@ describe("getReminderDispatchConfigFromEnv", () => { ], }); - const { reminderConfiguration } = getReminderDispatchConfigFromEnv(); - - expect(reminderConfiguration[OrderStatusCodes.DISPATCHED]).toEqual([ - { interval: 14, eventCode: "DISPATCHED_SECOND_REMINDER" }, - ]); + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "eventCode must be a valid NotifyEventCode", + ); }); }); }); diff --git a/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts b/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts index 74fd66fd..a25dea96 100644 --- a/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts @@ -1,11 +1,15 @@ +import { z } from "zod"; + import { type OrderStatusCode, OrderStatusCodes } from "../lib/db/order-status-db"; +import { NotifyEventCode } from "../lib/types/notify-message"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; -const allOrderStatusCodes = new Set(Object.values(OrderStatusCodes)); +const REMINDER_ENABLED_STATUSES = "REMINDER_ENABLED_STATUSES"; +const REMINDER_INTERVAL_CONFIG = "REMINDER_INTERVAL_CONFIG"; export interface ReminderScheduleConfig { interval: number; - eventCode: string; + eventCode: NotifyEventCode; } export type ReminderConfiguration = Partial>; @@ -15,79 +19,51 @@ export interface ReminderDispatchConfig { reminderConfiguration: ReminderConfiguration; } -function isOrderStatusCode(value: string): value is OrderStatusCode { - return allOrderStatusCodes.has(value as OrderStatusCode); -} +const EnabledReminderStatusesSchema = z + .array( + z.enum(OrderStatusCodes, { + error: (issue) => `"${String(issue.input)}" is not a valid order status code`, + }), + { + error: (issue) => + `${REMINDER_ENABLED_STATUSES} must be a JSON array of order status strings: ${String(issue.input)}`, + }, + ) + .min(1, { error: `${REMINDER_ENABLED_STATUSES} must contain at least one valid order status` }) + .transform((codes) => new Set(codes)); + +const ReminderScheduleConfigSchema = z.object({ + interval: z + .number({ error: (issue) => `interval must be a number, received: ${String(issue.input)}` }) + .positive({ + error: (issue) => `interval must be a positive number, received: ${String(issue.input)}`, + }) + .gte(1, { error: (issue) => `interval must be >= 1, received: ${String(issue.input)}` }), + eventCode: z.enum(NotifyEventCode, { + error: (issue) => + `eventCode must be a valid NotifyEventCode, received: "${String(issue.input)}"`, + }), +}); + +const ReminderConfigurationSchema = z + .record(z.string(), z.unknown(), { + error: () => `${REMINDER_INTERVAL_CONFIG} must be a JSON object`, + }) + .pipe(z.partialRecord(z.enum(OrderStatusCodes), z.array(ReminderScheduleConfigSchema))); function parseEnabledReminderStatuses(rawValue: string): ReadonlySet { const parsed = JSON.parse(rawValue) as unknown; - - if (!Array.isArray(parsed)) { - throw new TypeError("REMINDER_ENABLED_STATUSES must be a JSON array of order status strings"); - } - - const enabledStatuses = parsed.filter( - (status): status is OrderStatusCode => typeof status === "string" && isOrderStatusCode(status), - ); - - if (enabledStatuses.length === 0) { - throw new Error("REMINDER_ENABLED_STATUSES must contain at least one valid order status"); - } - - return new Set(enabledStatuses); + return EnabledReminderStatusesSchema.parse(parsed); } function parseReminderConfiguration(rawValue: string): ReminderConfiguration { const parsed = JSON.parse(rawValue) as unknown; - - if (!parsed || typeof parsed !== "object") { - throw new Error("REMINDER_INTERVAL_CONFIG must be a JSON object"); - } - - const result: ReminderConfiguration = {}; - - for (const [status, schedules] of Object.entries(parsed)) { - if (!isOrderStatusCode(status)) { - continue; - } - - if (!Array.isArray(schedules)) { - continue; - } - - const validSchedules = schedules - .map((schedule): ReminderScheduleConfig | null => { - if (!schedule || typeof schedule !== "object") { - return null; - } - - const rawInterval = (schedule as { interval?: unknown }).interval; - const rawEventCode = (schedule as { eventCode?: unknown }).eventCode; - - if (typeof rawInterval !== "number" || !Number.isFinite(rawInterval) || rawInterval <= 0) { - return null; - } - - if (typeof rawEventCode !== "string" || !rawEventCode.trim()) { - return null; - } - - return { - interval: rawInterval, - eventCode: rawEventCode, - }; - }) - .filter((schedule): schedule is ReminderScheduleConfig => schedule !== null); - - result[status] = validSchedules; - } - - return result; + return ReminderConfigurationSchema.parse(parsed) as ReminderConfiguration; } export function getReminderDispatchConfigFromEnv(): ReminderDispatchConfig { - const enabledStatusesRaw = retrieveMandatoryEnvVariable("REMINDER_ENABLED_STATUSES"); - const reminderIntervalConfigRaw = retrieveMandatoryEnvVariable("REMINDER_INTERVAL_CONFIG"); + const enabledStatusesRaw = retrieveMandatoryEnvVariable(REMINDER_ENABLED_STATUSES); + const reminderIntervalConfigRaw = retrieveMandatoryEnvVariable(REMINDER_INTERVAL_CONFIG); return { enabledReminderStatuses: parseEnabledReminderStatuses(enabledStatusesRaw), diff --git a/lambdas/src/reminder-dispatch-lambda/init.test.ts b/lambdas/src/reminder-dispatch-lambda/init.test.ts index b9807b69..494320e4 100644 --- a/lambdas/src/reminder-dispatch-lambda/init.test.ts +++ b/lambdas/src/reminder-dispatch-lambda/init.test.ts @@ -39,7 +39,8 @@ describe("init", () => { NOTIFY_MESSAGES_QUEUE_URL: "https://example.queue.local/notify", HOME_TEST_BASE_URL: "https://hometest.example.nhs.uk", REMINDER_ENABLED_STATUSES: '["DISPATCHED"]', - REMINDER_INTERVAL_CONFIG: '{"DISPATCHED":[{"interval":7,"eventCode":"reminder.dispatched"}]}', + REMINDER_INTERVAL_CONFIG: + '{"DISPATCHED":[{"interval":7,"eventCode":"DISPATCHED_INITIAL_REMINDER"}]}', }; const mockPostgresConfig = { From 0ed51163b674c766a39bc547a76f2ad081f52e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Wed, 15 Apr 2026 17:09:46 +0200 Subject: [PATCH 7/7] fix after review --- ...ispatched-reminder-message-builder.test.ts | 2 +- .../order-status-notify-service.test.ts | 25 +++++++++++++++++++ .../services/reminder-notify-service.test.ts | 2 +- .../services/reminder-notify-service.ts | 2 +- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts index 8bb2ea45..769a6d6a 100644 --- a/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts @@ -1,8 +1,8 @@ -import { NotifyEventCode } from "../../../../lib/types/notify-message"; import type { OrderDbClient } from "../../../db/order-db-client"; import type { OrderStatusService } from "../../../db/order-status-db"; import { OrderStatusCodes } from "../../../db/order-status-db"; import type { Patient, PatientDbClient } from "../../../db/patient-db-client"; +import { NotifyEventCode } from "../../../types/notify-message"; import { DispatchedReminderMessageBuilder } from "./dispatched-reminder-message-builder"; describe("DispatchedReminderMessageBuilder", () => { diff --git a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts index ea2b106e..ff91ebc4 100644 --- a/lambdas/src/lib/notify/services/order-status-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/order-status-notify-service.test.ts @@ -124,6 +124,31 @@ describe("OrderStatusNotifyService", () => { }); }); + it("should send and audit an order confirmed notification", async () => { + await service.dispatch({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusCode: OrderStatusCodes.CONFIRMED, + }); + + expect(mockBuildOrderConfirmedNotifyMessage).toHaveBeenCalledWith({ + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + orderId: statusUpdate.orderId, + }); + expect(mockSendMessage).toHaveBeenCalledWith( + "https://example.queue.local/notify", + expect.any(String), + ); + expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({ + messageReference: "123e4567-e89b-12d3-a456-426614174089", + eventCode: NotifyEventCode.OrderConfirmed, + correlationId: statusUpdate.correlationId, + status: NotificationAuditStatus.QUEUED, + }); + }); + it("should propagate errors when building the notify message fails", async () => { mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( new Error("Notify payload build failed"), diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts index e6641976..e9fb136f 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.test.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -1,6 +1,6 @@ -import { NotifyEventCode } from "../../../lib/types/notify-message"; import { NotificationAuditStatus } from "../../db/notification-audit-db-client"; import { OrderStatusCodes } from "../../db/order-status-db"; +import { NotifyEventCode } from "../../types/notify-message"; import { ReminderNotifyService, type ReminderNotifyServiceDependencies, diff --git a/lambdas/src/lib/notify/services/reminder-notify-service.ts b/lambdas/src/lib/notify/services/reminder-notify-service.ts index 307034ea..56009045 100644 --- a/lambdas/src/lib/notify/services/reminder-notify-service.ts +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -1,5 +1,5 @@ -import { NotifyEventCode } from "../../../lib/types/notify-message"; import { type OrderStatusCode, OrderStatusService } from "../../db/order-status-db"; +import { NotifyEventCode } from "../../types/notify-message"; import type { NotifyMessageBuilder } from "../message-builders/base-notify-message-builder"; import type { DispatchedReminderMessageBuilderInput } from "../message-builders/reminder/dispatched-reminder-message-builder"; import { BaseNotifyService, type NotifyServiceDependencies } from "./base-notify-service";