diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts index 9a4b904d..7822d41f 100644 --- a/lambdas/src/lib/db/notification-audit-db-client.ts +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -1,4 +1,4 @@ -import { type NotifyEventCode } from "../types/notify-message"; +import { NotifyEventCode } from "../types/notify-message"; import { type DBClient } from "./db-client"; export enum NotificationAuditStatus { 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..65ade3bf --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts @@ -0,0 +1,176 @@ +import { NotifyEventCode } from "../types/notify-message"; +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: NotifyEventCode.DispatchedInitialReminder, + }, + { + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 2, + intervalDays: 14, + eventCode: NotifyEventCode.DispatchedSecondReminder, + }, + ]; + + 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 new file mode 100644 index 00000000..017aa938 --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.ts @@ -0,0 +1,102 @@ +import { NotifyEventCode } from "../types/notify-message"; +import { type DBClient } from "./db-client"; +import { type OrderStatusCode } from "./order-status-db"; + +export interface OrderStatusReminderRecord { + reminderId: string; + orderUid: string; + triggerStatus: OrderStatusCode; + reminderNumber: number; + triggeredAt: Date; +} + +export interface ReminderScheduleTuple { + triggerStatus: string; + reminderNumber: number; + intervalDays: number; + eventCode: NotifyEventCode; +} + +export class OrderStatusReminderDbClient { + constructor(private readonly dbClient: DBClient) {} + + 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< + { + 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/message-builders/reminder/dispatched-reminder-message-builder.test.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.test.ts new file mode 100644 index 00000000..769a6d6a --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/reminder/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/reminder/dispatched-reminder-message-builder.ts b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts new file mode 100644 index 00000000..25d0df85 --- /dev/null +++ b/lambdas/src/lib/notify/message-builders/reminder/dispatched-reminder-message-builder.ts @@ -0,0 +1,45 @@ +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 DispatchedReminderMessageBuilderInput { + reminderId: string; + patientId: string; + orderId: string; + correlationId: string; + eventCode: NotifyEventCode; +} + +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/services/reminder-notify-service.test.ts b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts new file mode 100644 index 00000000..e9fb136f --- /dev/null +++ b/lambdas/src/lib/notify/services/reminder-notify-service.test.ts @@ -0,0 +1,128 @@ +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, +} from "./reminder-notify-service"; + +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({ + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: { build: mockBuildDispatchedReminderMessage }, + }, + orderStatusService: { getPatientIdFromOrder: mockGetPatientIdFromOrder }, + notificationAuditDbClient: { + insertNotificationAuditEntry: mockInsertNotificationAuditEntry, + } as never, + sqsClient: { sendMessage: mockSendMessage }, + notifyMessagesQueueUrl: "https://example.queue.local/notify", + ...deps, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatientIdFromOrder.mockResolvedValue("patient-123"); + mockBuildDispatchedReminderMessage.mockResolvedValue({ + messageReference: reminderId, + eventCode: "DISPATCHED_INITIAL_REMINDER", + 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: "DISPATCHED_INITIAL_REMINDER", + 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("throws when patient is not found", async () => { + mockGetPatientIdFromOrder.mockResolvedValueOnce(null); + + 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(); + 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..56009045 --- /dev/null +++ b/lambdas/src/lib/notify/services/reminder-notify-service.ts @@ -0,0 +1,58 @@ +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"; + +export interface ReminderNotifyServiceDependencies extends NotifyServiceDependencies { + notifyMessageBuilders: Partial< + Record> + >; + orderStatusService: Pick; +} + +export interface ReminderNotifyInput { + reminderId: string; + orderId: string; + correlationId: string; + statusCode: OrderStatusCode; + eventCode: NotifyEventCode; +} + +export class ReminderNotifyService extends BaseNotifyService { + 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 notifyMessageBuilder = this.notifyMessageBuilders[statusCode]; + if (!notifyMessageBuilder) { + return; + } + + const patientId = await this.orderStatusService.getPatientIdFromOrder(orderId); + + if (!patientId) { + throw new Error(`Patient not found for orderId ${orderId}`); + } + + const notifyMessage = await notifyMessageBuilder.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..46068f20 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -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 new file mode 100644 index 00000000..8c0ef165 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.test.ts @@ -0,0 +1,128 @@ +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 is an empty array", () => { + process.env.REMINDER_ENABLED_STATUSES = "[]"; + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "REMINDER_ENABLED_STATUSES must contain at least one valid order status", + ); + }); + + it("throws when REMINDER_ENABLED_STATUSES contains an unrecognised status", () => { + process.env.REMINDER_ENABLED_STATUSES = JSON.stringify([ + OrderStatusCodes.DISPATCHED, + "NOT_A_REAL_STATUS", + ]); + + expect(() => getReminderDispatchConfigFromEnv()).toThrow("is not a valid order status code"); + }); + }); + + 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("throws when REMINDER_INTERVAL_CONFIG contains an unrecognised status key", () => { + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + UNKNOWN_STATUS: [{ interval: 7, eventCode: "SOME_CODE" }], + }); + + expect(() => getReminderDispatchConfigFromEnv()).toThrow("Invalid key in record"); + }); + + 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" }, + { interval: 7, eventCode: "DISPATCHED_SECOND_REMINDER" }, + ], + }); + + expect(() => getReminderDispatchConfigFromEnv()).toThrow( + "interval must be a positive number, received: 0", + ); + }); + + it("throws when a schedule entry has an invalid eventCode", () => { + process.env.REMINDER_INTERVAL_CONFIG = JSON.stringify({ + [OrderStatusCodes.DISPATCHED]: [ + { interval: 7, eventCode: "" }, + { 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 new file mode 100644 index 00000000..a25dea96 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/dispatch-config.ts @@ -0,0 +1,72 @@ +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 REMINDER_ENABLED_STATUSES = "REMINDER_ENABLED_STATUSES"; +const REMINDER_INTERVAL_CONFIG = "REMINDER_INTERVAL_CONFIG"; + +export interface ReminderScheduleConfig { + interval: number; + eventCode: NotifyEventCode; +} + +export type ReminderConfiguration = Partial>; + +export interface ReminderDispatchConfig { + enabledReminderStatuses: ReadonlySet; + reminderConfiguration: ReminderConfiguration; +} + +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; + return EnabledReminderStatusesSchema.parse(parsed); +} + +function parseReminderConfiguration(rawValue: string): ReminderConfiguration { + const parsed = JSON.parse(rawValue) as unknown; + return ReminderConfigurationSchema.parse(parsed) as ReminderConfiguration; +} + +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..f75b01a0 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/index.test.ts @@ -0,0 +1,175 @@ +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 { lambdaHandler } from "./index"; +import { init } from "./init"; + +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", + 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", + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 2, + triggeredAt: TRIGGERED_AT, +}; + +describe("reminder-dispatch-lambda", () => { + const mockNotify = jest.fn, [unknown]>().mockResolvedValue(undefined); + 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); + + 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(); + + mockGetScheduledReminders.mockResolvedValue([DISPATCHED_REMINDER_1, DISPATCHED_REMINDER_2]); + + mockedInit.mockReturnValue({ + reminderNotifyService: { + dispatch: mockNotify, + }, + orderStatusReminderDbClient: { + getScheduledReminders: mockGetScheduledReminders, + markReminderAsQueued: mockMarkReminderAsQueued, + 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); + }); + + 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: "DISPATCHED_INITIAL_REMINDER", + }); + + expect(mockNotify.mock.calls[1][0]).toMatchObject({ + reminderId: DISPATCHED_REMINDER_2.reminderId, + orderId: DISPATCHED_REMINDER_2.orderUid, + correlationId: mockEvent.id, + statusCode: OrderStatusCodes.DISPATCHED, + eventCode: "DISPATCHED_SECOND_REMINDER", + }); + }); + + 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 () => { + mockedInit.mockReturnValueOnce({ + ...mockedInit(), + enabledReminderStatuses: new Set([OrderStatusCodes.RECEIVED]), + } as unknown as ReturnType); + + 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 () => { + 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: "DISPATCHED_INITIAL_REMINDER", + }); + }); + + it("throws and re-throws when getScheduledReminders rejects", async () => { + const error = new Error("DB connection failed"); + 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 new file mode 100644 index 00000000..78a0a678 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/index.ts @@ -0,0 +1,169 @@ +import { Context, EventBridgeEvent } from "aws-lambda"; + +import { ConsoleCommons } from "../lib/commons"; +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, + enabledReminderStatuses, + reminderConfiguration, + } = init(); + + 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 + const schedules = buildSchedules(reminderConfiguration); + const reminders = await orderStatusReminderDbClient.getScheduledReminders(schedules); + + const outcomes: ReminderOutcome[] = []; + for (const reminder of reminders) { + outcomes.push( + await processReminder(reminder, { + reminderNotifyService, + orderStatusReminderDbClient, + schedules, + enabledReminderStatuses, + correlationId, + }), + ); + } + + const countFunc = (outcome: ReminderOutcome) => outcomes.filter((o) => o === outcome).length; + + commons.logInfo(name, "Reminder dispatch completed", { + correlationId, + 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 }); + throw error; + } +}; + +export const handler = lambdaHandler; 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..494320e4 --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/init.test.ts @@ -0,0 +1,220 @@ +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", + REMINDER_ENABLED_STATUSES: '["DISPATCHED"]', + REMINDER_INTERVAL_CONFIG: + '{"DISPATCHED":[{"interval":7,"eventCode":"DISPATCHED_INITIAL_REMINDER"}]}', + }; + + 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), + enabledReminderStatuses: expect.any(Set), + reminderConfiguration: expect.any(Object), + }); + }); + + 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 throw when AWS_REGION is not set", () => { + delete process.env.AWS_REGION; + + expect(() => init()).toThrow("Missing value for an environment variable AWS_REGION"); + }); + + 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(); + }); + }); + }); +}); diff --git a/lambdas/src/reminder-dispatch-lambda/init.ts b/lambdas/src/reminder-dispatch-lambda/init.ts new file mode 100644 index 00000000..d38d768c --- /dev/null +++ b/lambdas/src/reminder-dispatch-lambda/init.ts @@ -0,0 +1,65 @@ +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 { 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"; +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"; +import { type ReminderConfiguration, getReminderDispatchConfigFromEnv } from "./dispatch-config"; + +export interface Environment { + reminderNotifyService: ReminderNotifyService; + orderStatusReminderDbClient: OrderStatusReminderDbClient; + enabledReminderStatuses: ReadonlySet; + reminderConfiguration: ReminderConfiguration; +} + +export function buildEnvironment(): Environment { + const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); + 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(awsRegion); + const builderDeps = { patientDbClient, orderDbClient, homeTestBaseUrl }; + const reminderNotifyService = new ReminderNotifyService({ + notifyMessageBuilders: { + [OrderStatusCodes.DISPATCHED]: new DispatchedReminderMessageBuilder( + builderDeps, + orderStatusDb, + ), + }, + orderStatusService: orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessagesQueueUrl, + }); + + const { enabledReminderStatuses, reminderConfiguration } = getReminderDispatchConfigFromEnv(); + + return { + reminderNotifyService, + orderStatusReminderDbClient, + enabledReminderStatuses, + reminderConfiguration, + }; +} + +let _env: Environment | undefined; + +export function init(): Environment { + _env ??= buildEnvironment(); + return _env; +} 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..ffc44dd9 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" @@ -171,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, + ] } ] }) @@ -534,6 +538,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 = 180 + + 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