diff --git a/lambdas/src/lib/db/notification-audit-db-client.test.ts b/lambdas/src/lib/db/notification-audit-db-client.test.ts new file mode 100644 index 00000000..1e306135 --- /dev/null +++ b/lambdas/src/lib/db/notification-audit-db-client.test.ts @@ -0,0 +1,66 @@ +import { NotifyEventCode } from "../types/notify-message"; +import { type DBClient } from "./db-client"; +import { + NotificationAuditDbClient, + type NotificationAuditEntryParams, + NotificationAuditStatus, +} from "./notification-audit-db-client"; + +const mockQuery = jest.fn(); + +describe("NotificationAuditDbClient", () => { + let client: NotificationAuditDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + const dbClient: DBClient = { + query: mockQuery, + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + client = new NotificationAuditDbClient(dbClient); + }); + + it("should insert notification audit entry", async () => { + const params: NotificationAuditEntryParams = { + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: NotificationAuditStatus.SENT, + }; + + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 1, + }); + + await expect(client.insertNotificationAuditEntry(params)).resolves.toBeUndefined(); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("notification_audit"), [ + params.messageReference, + null, + params.eventCode, + null, + params.correlationId, + params.status, + ]); + }); + + it("should throw when notification audit insert affects no rows", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect( + client.insertNotificationAuditEntry({ + messageReference: "123e4567-e89b-12d3-a456-426614174000", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: "123e4567-e89b-12d3-a456-426614174001", + status: NotificationAuditStatus.SENT, + }), + ).rejects.toThrow("Failed to insert notification audit entry"); + }); +}); diff --git a/lambdas/src/lib/db/notification-audit-db-client.ts b/lambdas/src/lib/db/notification-audit-db-client.ts new file mode 100644 index 00000000..9a4b904d --- /dev/null +++ b/lambdas/src/lib/db/notification-audit-db-client.ts @@ -0,0 +1,66 @@ +import { type NotifyEventCode } from "../types/notify-message"; +import { type DBClient } from "./db-client"; + +export enum NotificationAuditStatus { + QUEUED = "QUEUED", + SENT = "SENT", + FAILED = "FAILED", +} + +export interface NotificationAuditEntryParams { + messageReference: string; + eventCode: NotifyEventCode; + correlationId: string; + status: NotificationAuditStatus; + notifyMessageId?: string | null; + routingPlanId?: string | null; +} + +export class NotificationAuditDbClient { + constructor(private readonly dbClient: DBClient) {} + + async insertNotificationAuditEntry(params: NotificationAuditEntryParams): Promise { + const { + messageReference, + notifyMessageId = null, + eventCode, + routingPlanId = null, + correlationId, + status, + } = params; + + const query = ` + INSERT INTO notification_audit ( + message_reference, + notify_message_id, + event_code, + routing_plan_id, + correlation_id, + status + ) + VALUES ($1::uuid, $2, $3, $4::uuid, $5::uuid, $6) + `; + + try { + const result = await this.dbClient.query(query, [ + messageReference, + notifyMessageId, + eventCode, + routingPlanId, + correlationId, + status, + ]); + + if (result.rowCount === 0) { + throw new Error("Failed to insert notification audit entry"); + } + } catch (error) { + throw new Error( + `Failed to insert notification audit entry for messageReference ${messageReference}`, + { + cause: error, + }, + ); + } + } +} diff --git a/lambdas/src/lib/db/order-status-db.test.ts b/lambdas/src/lib/db/order-status-db.test.ts index 27f86222..12047b51 100644 --- a/lambdas/src/lib/db/order-status-db.test.ts +++ b/lambdas/src/lib/db/order-status-db.test.ts @@ -1,3 +1,4 @@ +import { DBClient } from "./db-client"; import { OrderRow, OrderStatusCodes, @@ -18,11 +19,12 @@ describe("OrderStatusService", () => { beforeEach(() => { jest.clearAllMocks(); - const mockDbClient = { + const mockDbClient: DBClient = { query: mockQuery, - close: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + withTransaction: jest.fn(), }; - service = new OrderStatusService(mockDbClient as any); + service = new OrderStatusService(mockDbClient); }); describe("getPatientIdFromOrder", () => { @@ -153,4 +155,38 @@ describe("OrderStatusService", () => { ).rejects.toThrow("Failed to update order status"); }); }); + + describe("isFirstStatusOccurrence", () => { + it("should return true for first occurrence", async () => { + mockQuery.mockResolvedValue({ + rows: [{ count: 1 }], + rowCount: 1, + }); + + const result = await service.isFirstStatusOccurrence( + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ); + + expect(result).toBe(true); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("COUNT(*)::int"), [ + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ]); + }); + + it("should return false when status already exists", async () => { + mockQuery.mockResolvedValue({ + rows: [{ count: 2 }], + rowCount: 1, + }); + + const result = await service.isFirstStatusOccurrence( + "some-mocked-order-id", + OrderStatusCodes.DISPATCHED, + ); + + expect(result).toBe(false); + }); + }); }); diff --git a/lambdas/src/lib/db/order-status-db.ts b/lambdas/src/lib/db/order-status-db.ts index cb3e4ea5..0ac82e42 100644 --- a/lambdas/src/lib/db/order-status-db.ts +++ b/lambdas/src/lib/db/order-status-db.ts @@ -115,4 +115,28 @@ export class OrderStatusService { }); } } + + async isFirstStatusOccurrence(orderId: string, statusCode: OrderStatusCode): Promise { + const query = ` + SELECT COUNT(*)::int AS count + FROM order_status + WHERE order_uid = $1::uuid AND status_code = $2; + `; + + try { + const result = await this.dbClient.query<{ count: number }, [string, OrderStatusCode]>( + query, + [orderId, statusCode], + ); + + return result.rows[0]?.count === 1; + } catch (error) { + throw new Error( + `Failed to verify first occurrence for orderId ${orderId} and statusCode ${statusCode}`, + { + cause: error, + }, + ); + } + } } diff --git a/lambdas/src/lib/db/patient-db-client.test.ts b/lambdas/src/lib/db/patient-db-client.test.ts new file mode 100644 index 00000000..cb1c873f --- /dev/null +++ b/lambdas/src/lib/db/patient-db-client.test.ts @@ -0,0 +1,55 @@ +import { type DBClient } from "./db-client"; +import { PatientDbClient } from "./patient-db-client"; + +const mockQuery = jest.fn(); + +describe("PatientDbClient", () => { + let patientDbClient: PatientDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + const dbClient: DBClient = { + query: mockQuery, + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + patientDbClient = new PatientDbClient(dbClient); + }); + + describe("get", () => { + it("should return notify recipient data", async () => { + mockQuery.mockResolvedValue({ + rows: [ + { + nhs_number: "1234567890", + birth_date: "1990-04-20", + }, + ], + rowCount: 1, + }); + + const result = await patientDbClient.get("some-mocked-patient-id"); + + expect(result).toEqual({ + nhsNumber: "1234567890", + birthDate: "1990-04-20", + }); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("patient_mapping"), [ + "some-mocked-patient-id", + ]); + }); + + it("should throw when patient record does not exist", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect(patientDbClient.get("missing-patient-id")).rejects.toThrow( + "Failed to fetch notify recipient data", + ); + }); + }); +}); diff --git a/lambdas/src/lib/db/patient-db-client.ts b/lambdas/src/lib/db/patient-db-client.ts new file mode 100644 index 00000000..a927f8fb --- /dev/null +++ b/lambdas/src/lib/db/patient-db-client.ts @@ -0,0 +1,41 @@ +import { type DBClient } from "./db-client"; + +export interface Patient { + nhsNumber: string; + birthDate: string; +} + +export class PatientDbClient { + constructor(private readonly dbClient: DBClient) {} + + async get(patientId: string): Promise { + const query = ` + SELECT nhs_number, birth_date + FROM patient_mapping + WHERE patient_uid = $1::uuid + LIMIT 1; + `; + + try { + const result = await this.dbClient.query< + { nhs_number: string; birth_date: string }, + [string] + >(query, [patientId]); + + if (result.rowCount === 0 || !result.rows[0]) { + throw new Error(`Notify recipient not found for patientId ${patientId}`); + } + + const row = result.rows[0]; + + return { + nhsNumber: row.nhs_number, + birthDate: row.birth_date, + }; + } catch (error) { + throw new Error(`Failed to fetch notify recipient data for patientId ${patientId}`, { + cause: error, + }); + } + } +} diff --git a/lambdas/src/lib/types/notify-message.ts b/lambdas/src/lib/types/notify-message.ts index 9c82cbb9..5622e8ad 100644 --- a/lambdas/src/lib/types/notify-message.ts +++ b/lambdas/src/lib/types/notify-message.ts @@ -13,4 +13,5 @@ export interface NotifyRecipient { export enum NotifyEventCode { OrderConfirmed = "ORDER_CONFIRMED", + OrderDispatched = "ORDER_DISPATCHED", } diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 272c470d..9ba8c161 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -10,6 +10,7 @@ const mockInit = jest.fn(); const mockGetPatientIdFromOrder = jest.fn(); const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); +const mockHandleOrderStatusUpdated = jest.fn(); const mockGetCorrelationIdFromEventHeaders = jest.fn(); @@ -41,6 +42,7 @@ describe("Order Status Lambda Handler", () => { mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); + mockHandleOrderStatusUpdated.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { @@ -48,6 +50,9 @@ describe("Order Status Lambda Handler", () => { checkIdempotency: mockCheckIdempotency, addOrderStatusUpdate: mockAddOrderStatusUpdate, }, + orderStatusNotifyService: { + handleOrderStatusUpdated: mockHandleOrderStatusUpdated, + }, }); const module = await import("./index"); @@ -272,6 +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(); }); it("should process new updates with different correlation ID", async () => { @@ -398,6 +404,56 @@ describe("Order Status Lambda Handler", () => { }), ); }); + + it("should delegate post-update side effects to the notification service", async () => { + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + patientId: MOCK_PATIENT_UID, + correlationId: MOCK_CORRELATION_ID, + orderId: MOCK_ORDER_UID, + statusUpdate: expect.objectContaining({ + orderId: MOCK_ORDER_UID, + statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], + createdAt: validTaskBody.lastModified, + correlationId: MOCK_CORRELATION_ID, + }), + }), + ); + }); + + it("should still delegate non-dispatched statuses to the notification service", async () => { + mockEvent.body = JSON.stringify({ + ...validTaskBody, + businessStatus: { + text: IncomingBusinessStatus.RECEIVED_AT_LAB, + }, + } satisfies Partial); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + statusUpdate: expect.objectContaining({ + statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + }), + }), + ); + }); + + it("should return 500 when notification service fails", async () => { + mockHandleOrderStatusUpdated.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); + }); }); describe("Error Handling", () => { diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 6384bd0d..b7a3c0ac 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -3,10 +3,10 @@ import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import { OrderStatusUpdateParams } from "src/lib/db/order-status-db"; import z from "zod"; import { ConsoleCommons } from "../lib/commons"; +import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; import { @@ -42,7 +42,7 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb } = init(); + const { orderStatusDb, orderStatusNotifyService } = init(); commons.logInfo(name, "Received order status update request", { path: event.path, method: event.httpMethod, @@ -161,6 +161,13 @@ export const lambdaHandler = async ( commons.logInfo(name, "Order status update added successfully", statusOrderUpdateParams); + await orderStatusNotifyService.handleOrderStatusUpdated({ + orderId, + patientId: orderPatientId, + correlationId, + statusUpdate: statusOrderUpdateParams, + }); + return createFhirResponse(201, validatedTask); } catch (error) { commons.logError(name, "Error processing order status update", { diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 203bbede..c37fa91e 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -1,15 +1,25 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; +import { PatientDbClient } from "../lib/db/patient-db-client"; 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"; +import { NotifyMessageBuilder } from "./notify-message-builder"; +import { OrderStatusNotifyService } from "./notify-service"; jest.mock("../lib/db/order-status-db"); +jest.mock("../lib/db/patient-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("./notify-message-builder"); +jest.mock("./notify-service"); describe("init", () => { const originalEnv = process.env; @@ -21,6 +31,8 @@ describe("init", () => { DB_NAME: "test-database", DB_SCHEMA: "test-schema", DB_SECRET_NAME: "test-secret-name", + NOTIFY_MESSAGES_QUEUE_URL: "https://example.queue.local/notify", + HOME_TEST_BASE_URL: "https://hometest.example.nhs.uk", }; const mockPostgresConfig = { @@ -97,6 +109,7 @@ describe("init", () => { expect(result).toEqual({ orderStatusDb: expect.any(OrderStatusService), + orderStatusNotifyService: expect.any(OrderStatusNotifyService), }); }); }); @@ -133,9 +146,52 @@ describe("init", () => { times: 1, calledWith: expect.any(PostgresDbClient), }, + { + mock: PatientDbClient 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: NotifyMessageBuilder as jest.Mock, + times: 1, + }, + { + mock: OrderStatusNotifyService as jest.Mock, + times: 1, + }, ], }); }); + + it("should create NotifyMessageBuilder with PatientDbClient and home test base url", () => { + init(); + + expect(NotifyMessageBuilder).toHaveBeenCalledWith( + expect.any(PatientDbClient), + "https://hometest.example.nhs.uk", + ); + }); + + it("should create OrderStatusNotifyService with notification dependencies", () => { + init(); + + expect(OrderStatusNotifyService).toHaveBeenCalledWith({ + orderStatusDb: expect.any(OrderStatusService), + notificationAuditDbClient: expect.any(NotificationAuditDbClient), + sqsClient: expect.any(AWSSQSClient), + notifyMessageBuilder: expect.any(NotifyMessageBuilder), + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }); + }); }); describe("singleton protection", () => { diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 1909e9b3..e2a30284 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -1,20 +1,42 @@ import { PostgresDbClient } from "../lib/db/db-client"; import { postgresConfigFromEnv } from "../lib/db/db-config"; +import { NotificationAuditDbClient } from "../lib/db/notification-audit-db-client"; import { OrderStatusService } from "../lib/db/order-status-db"; +import { PatientDbClient } from "../lib/db/patient-db-client"; import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; +import { AWSSQSClient } from "../lib/sqs/sqs-client"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { NotifyMessageBuilder } from "./notify-message-builder"; +import { OrderStatusNotifyService } from "./notify-service"; export interface Environment { orderStatusDb: OrderStatusService; + orderStatusNotifyService: OrderStatusNotifyService; } 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 patientDbClient = new PatientDbClient(dbClient); + const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); + const sqsClient = new AWSSQSClient(); + const notifyMessageBuilder = new NotifyMessageBuilder(patientDbClient, homeTestBaseUrl); + const orderStatusNotifyService = new OrderStatusNotifyService({ + orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessageBuilder, + notifyMessagesQueueUrl, + }); return { orderStatusDb, + orderStatusNotifyService, }; } diff --git a/lambdas/src/order-status-lambda/notify-message-builder.test.ts b/lambdas/src/order-status-lambda/notify-message-builder.test.ts new file mode 100644 index 00000000..db6f9364 --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-message-builder.test.ts @@ -0,0 +1,81 @@ +import type { Patient, PatientDbClient } from "../lib/db/patient-db-client"; +import { NotifyEventCode } from "../lib/types/notify-message"; +import { NotifyMessageBuilder } from "./notify-message-builder"; + +describe("NotifyMessageBuilder", () => { + const mockGetPatient = jest.fn, [string]>(); + + const mockPatientDbClient: Pick = { + get: mockGetPatient, + }; + + let builder: NotifyMessageBuilder; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPatient.mockResolvedValue({ + nhsNumber: "1234567890", + birthDate: "1990-01-02", + }); + + builder = new NotifyMessageBuilder( + mockPatientDbClient as PatientDbClient, + "https://hometest.example.nhs.uk", + ); + }); + + it("should build dispatched notify message with formatted date and tracking url", async () => { + const result = await builder.buildOrderDispatchedNotifyMessage({ + patientId: "550e8400-e29b-41d4-a716-446655440111", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + orderId: "550e8400-e29b-41d4-a716-446655440000", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + 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", + }); + }); + + it("should normalize trailing slash in base url", async () => { + const trailingSlashBuilder = new NotifyMessageBuilder( + mockPatientDbClient as PatientDbClient, + "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", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + 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 () => { + await builder.buildOrderDispatchedNotifyMessage({ + patientId: "550e8400-e29b-41d4-a716-446655440111", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + orderId: "550e8400-e29b-41d4-a716-446655440000", + dispatchedAt: "2026-08-06T10:00:00Z", + }); + + expect(mockGetPatient).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440111"); + }); +}); diff --git a/lambdas/src/order-status-lambda/notify-message-builder.ts b/lambdas/src/order-status-lambda/notify-message-builder.ts new file mode 100644 index 00000000..48651f34 --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-message-builder.ts @@ -0,0 +1,55 @@ +import { v4 as uuidv4 } from "uuid"; + +import type { PatientDbClient } from "../lib/db/patient-db-client"; +import { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../lib/types/notify-message"; + +export interface BuildOrderDispatchedNotifyMessageInput { + patientId: string; + correlationId: string; + orderId: string; + dispatchedAt: string; +} + +const formatDispatchedDate = (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, + homeTestBaseUrl: string, + ) { + this.normalizedHomeTestBaseUrl = homeTestBaseUrl.replaceAll(/\/$/g, ""); + } + + async buildOrderDispatchedNotifyMessage( + input: BuildOrderDispatchedNotifyMessageInput, + ): Promise { + const { patientId, correlationId, orderId, dispatchedAt } = input; + + const patient = await this.patientDbClient.get(patientId); + const recipient: NotifyRecipient = { + nhsNumber: patient.nhsNumber, + dateOfBirth: patient.birthDate, + }; + + const trackingUrl = `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`; + + return { + correlationId, + messageReference: uuidv4(), + eventCode: NotifyEventCode.OrderDispatched, + recipient, + personalisation: { + dispatchedDate: formatDispatchedDate(dispatchedAt), + orderLinkUrl: trackingUrl, + }, + }; + } +} diff --git a/lambdas/src/order-status-lambda/notify-service.test.ts b/lambdas/src/order-status-lambda/notify-service.test.ts new file mode 100644 index 00000000..b22d239b --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-service.test.ts @@ -0,0 +1,149 @@ +import { NotificationAuditStatus } from "../lib/db/notification-audit-db-client"; +import { OrderStatusCodes, OrderStatusUpdateParams } from "../lib/db/order-status-db"; +import { NotifyEventCode } from "../lib/types/notify-message"; +import { OrderStatusNotifyService } from "./notify-service"; + +describe("OrderStatusNotifyService", () => { + const mockIsFirstStatusOccurrence = jest.fn, [string, string]>(); + const mockBuildOrderDispatchedNotifyMessage = jest.fn(); + const mockSendMessage = jest.fn(); + const mockInsertNotificationAuditEntry = jest.fn(); + + const statusUpdate: OrderStatusUpdateParams = { + orderId: "550e8400-e29b-41d4-a716-446655440000", + statusCode: OrderStatusCodes.DISPATCHED, + createdAt: "2024-01-15T10:00:00Z", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + }; + + let service: OrderStatusNotifyService; + + beforeEach(() => { + jest.clearAllMocks(); + + mockIsFirstStatusOccurrence.mockResolvedValue(true); + mockBuildOrderDispatchedNotifyMessage.mockResolvedValue({ + messageReference: "123e4567-e89b-12d3-a456-426614174099", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: statusUpdate.correlationId, + recipient: { + nhsNumber: "1234567890", + dateOfBirth: "1990-01-02", + }, + personalisation: {}, + }); + mockSendMessage.mockResolvedValue({ messageId: "message-id" }); + mockInsertNotificationAuditEntry.mockResolvedValue(undefined); + + service = new OrderStatusNotifyService({ + orderStatusDb: { + isFirstStatusOccurrence: mockIsFirstStatusOccurrence, + } as never, + notificationAuditDbClient: { + insertNotificationAuditEntry: mockInsertNotificationAuditEntry, + } as never, + sqsClient: { + sendMessage: mockSendMessage, + }, + notifyMessageBuilder: { + buildOrderDispatchedNotifyMessage: mockBuildOrderDispatchedNotifyMessage, + } as never, + notifyMessagesQueueUrl: "https://example.queue.local/notify", + }); + }); + + it("should do nothing for statuses without side effects", async () => { + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate: { + ...statusUpdate, + statusCode: OrderStatusCodes.RECEIVED, + }, + }); + + expect(mockIsFirstStatusOccurrence).not.toHaveBeenCalled(); + expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should not send a dispatched notification when it is not the first occurrence", async () => { + mockIsFirstStatusOccurrence.mockResolvedValueOnce(false); + + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }); + + expect(mockIsFirstStatusOccurrence).toHaveBeenCalledWith( + statusUpdate.orderId, + OrderStatusCodes.DISPATCHED, + ); + expect(mockBuildOrderDispatchedNotifyMessage).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should send and audit the first dispatched notification", async () => { + await service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }); + + expect(mockBuildOrderDispatchedNotifyMessage).toHaveBeenCalledWith({ + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + orderId: statusUpdate.orderId, + dispatchedAt: statusUpdate.createdAt, + }); + expect(mockSendMessage).toHaveBeenCalledWith( + "https://example.queue.local/notify", + expect.any(String), + ); + expect(mockInsertNotificationAuditEntry).toHaveBeenCalledWith({ + messageReference: "123e4567-e89b-12d3-a456-426614174099", + eventCode: NotifyEventCode.OrderDispatched, + correlationId: statusUpdate.correlationId, + status: NotificationAuditStatus.QUEUED, + }); + }); + + it("should swallow errors when building the notify message fails", async () => { + mockBuildOrderDispatchedNotifyMessage.mockRejectedValueOnce( + new Error("Notify payload build failed"), + ); + + await expect( + service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }), + ).resolves.toBeUndefined(); + + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); + + it("should swallow errors when sending the notify message fails", async () => { + mockSendMessage.mockRejectedValueOnce(new Error("SQS unavailable")); + + await expect( + service.handleOrderStatusUpdated({ + orderId: statusUpdate.orderId, + patientId: "patient-123", + correlationId: statusUpdate.correlationId, + statusUpdate, + }), + ).resolves.toBeUndefined(); + + expect(mockInsertNotificationAuditEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/src/order-status-lambda/notify-service.ts b/lambdas/src/order-status-lambda/notify-service.ts new file mode 100644 index 00000000..9110e79f --- /dev/null +++ b/lambdas/src/order-status-lambda/notify-service.ts @@ -0,0 +1,90 @@ +import { ConsoleCommons } from "../lib/commons"; +import { + NotificationAuditDbClient, + NotificationAuditStatus, +} from "../lib/db/notification-audit-db-client"; +import { + OrderStatusCodes, + OrderStatusService, + OrderStatusUpdateParams, +} from "../lib/db/order-status-db"; +import { SQSClientInterface } from "../lib/sqs/sqs-client"; +import { NotifyMessageBuilder } from "./notify-message-builder"; + +const commons = new ConsoleCommons(); +const name = "order-status-lambda"; + +export interface OrderStatusNotifyServiceDependencies { + orderStatusDb: OrderStatusService; + notificationAuditDbClient: NotificationAuditDbClient; + sqsClient: SQSClientInterface; + notifyMessageBuilder: NotifyMessageBuilder; + notifyMessagesQueueUrl: string; +} + +export interface HandleOrderStatusUpdatedInput { + orderId: string; + patientId: string; + correlationId: string; + statusUpdate: OrderStatusUpdateParams; +} + +export class OrderStatusNotifyService { + constructor(private readonly dependencies: OrderStatusNotifyServiceDependencies) {} + + async handleOrderStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise { + const { statusUpdate } = input; + + switch (statusUpdate.statusCode) { + case OrderStatusCodes.DISPATCHED: + await this.handleDispatchedStatusUpdated(input); + return; + default: + return; + } + } + + private async handleDispatchedStatusUpdated(input: HandleOrderStatusUpdatedInput): Promise { + const { orderId, patientId, correlationId, statusUpdate } = input; + const { + orderStatusDb, + notificationAuditDbClient, + sqsClient, + notifyMessageBuilder, + notifyMessagesQueueUrl, + } = this.dependencies; + + try { + const isFirstDispatched = await orderStatusDb.isFirstStatusOccurrence( + orderId, + OrderStatusCodes.DISPATCHED, + ); + + if (!isFirstDispatched) { + return; + } + + const notifyMessage = await notifyMessageBuilder.buildOrderDispatchedNotifyMessage({ + patientId, + correlationId, + orderId, + dispatchedAt: statusUpdate.createdAt, + }); + + 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 dispatched notification", { + correlationId, + orderId, + error, + }); + } + } +} diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 881e9bc2..cb5825bd 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -519,15 +519,17 @@ module "order_status_lambda" { lambda_role_policy_attachment = aws_iam_role_policy_attachment.lambda_basic environment_variables = { - NODE_OPTIONS = "--enable-source-maps" - ALLOW_ORIGIN = "http://localhost:3000" - 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" + NODE_OPTIONS = "--enable-source-maps" + ALLOW_ORIGIN = "http://localhost:3000" + 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" } }