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..3d5de00c --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.test.ts @@ -0,0 +1,67 @@ +import { type DBClient } from "./db-client"; +import { OrderStatusCodes } from "./order-status-db"; +import { + InsertOrderStatusReminderParams, + OrderStatusReminderDbClient, + OrderStatusReminderStatus, +} from "./order-status-reminder-db-client"; + +const mockQuery = jest.fn(); + +describe("OrderStatusReminderDbClient", () => { + let client: OrderStatusReminderDbClient; + + beforeEach(() => { + jest.clearAllMocks(); + + const dbClient: DBClient = { + query: mockQuery, + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + client = new OrderStatusReminderDbClient(dbClient); + }); + + it("should insert order status reminder", async () => { + const params: InsertOrderStatusReminderParams = { + orderId: "123e4567-e89b-12d3-a456-426614174000", + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + status: OrderStatusReminderStatus.SCHEDULED, + triggeredAt: "2026-04-14T10:00:00.000Z", + }; + + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 1, + }); + + await expect(client.insertOrderStatusReminder(params)).resolves.toBeUndefined(); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("order_status_reminder"), [ + params.orderId, + params.triggerStatus, + params.reminderNumber, + params.status, + params.triggeredAt, + ]); + }); + + it("should throw when order status reminder insert affects no rows", async () => { + mockQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + }); + + await expect( + client.insertOrderStatusReminder({ + orderId: "123e4567-e89b-12d3-a456-426614174000", + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + status: OrderStatusReminderStatus.SCHEDULED, + triggeredAt: "2026-04-14T10:00:00.000Z", + }), + ).rejects.toThrow("Failed to insert order status reminder"); + }); +}); 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..7abb4998 --- /dev/null +++ b/lambdas/src/lib/db/order-status-reminder-db-client.ts @@ -0,0 +1,54 @@ +import { type DBClient } from "./db-client"; +import { type OrderStatusCode } from "./order-status-db"; + +export enum OrderStatusReminderStatus { + SCHEDULED = "SCHEDULED", + QUEUED = "QUEUED", + FAILED = "FAILED", + CANCELLED = "CANCELLED", +} + +export interface InsertOrderStatusReminderParams { + orderId: string; + triggerStatus: OrderStatusCode; + reminderNumber: number; + status: OrderStatusReminderStatus; + triggeredAt: string; +} + +export class OrderStatusReminderDbClient { + constructor(private readonly dbClient: DBClient) {} + + async insertOrderStatusReminder(params: InsertOrderStatusReminderParams): Promise { + const { orderId, triggerStatus, reminderNumber, status, triggeredAt } = params; + + const query = ` + INSERT INTO order_status_reminder ( + order_uid, + trigger_status, + reminder_number, + status, + triggered_at + ) + VALUES ($1::uuid, $2, $3::smallint, $4::reminder_status, $5::timestamptz) + `; + + try { + const result = await this.dbClient.query(query, [ + orderId, + triggerStatus, + reminderNumber, + status, + triggeredAt, + ]); + + if (result.rowCount === 0) { + throw new Error("Failed to insert order status reminder"); + } + } catch (error) { + throw new Error(`Failed to insert order status reminder for orderId ${orderId}`, { + cause: error, + }); + } + } +} diff --git a/lambdas/src/lib/reminder/order-status-reminder-service.test.ts b/lambdas/src/lib/reminder/order-status-reminder-service.test.ts new file mode 100644 index 00000000..6c99f970 --- /dev/null +++ b/lambdas/src/lib/reminder/order-status-reminder-service.test.ts @@ -0,0 +1,70 @@ +import { OrderStatusCodes } from "../db/order-status-db"; +import { OrderStatusReminderStatus } from "../db/order-status-reminder-db-client"; +import { OrderStatusReminderService } from "./order-status-reminder-service"; + +describe("OrderStatusReminderService", () => { + const mockInsertOrderStatusReminder = jest.fn(); + + let service: OrderStatusReminderService; + + beforeEach(() => { + jest.clearAllMocks(); + + mockInsertOrderStatusReminder.mockResolvedValue(undefined); + + service = new OrderStatusReminderService({ + orderStatusReminderDbClient: { + insertOrderStatusReminder: mockInsertOrderStatusReminder, + } as never, + }); + }); + + it("should do nothing for statuses without reminder side effects", async () => { + await service.handleOrderStatusUpdated({ + orderId: "550e8400-e29b-41d4-a716-446655440000", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + statusCode: OrderStatusCodes.CONFIRMED, + triggeredAt: "2024-01-15T10:00:00Z", + }); + + expect(mockInsertOrderStatusReminder).not.toHaveBeenCalled(); + }); + + it("should schedule initial reminder for dispatched status", async () => { + const triggeredAt = "2024-01-15T10:00:00Z"; + + await service.handleOrderStatusUpdated({ + orderId: "550e8400-e29b-41d4-a716-446655440000", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + statusCode: OrderStatusCodes.DISPATCHED, + triggeredAt, + }); + + expect(mockInsertOrderStatusReminder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: "550e8400-e29b-41d4-a716-446655440000", + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + status: OrderStatusReminderStatus.SCHEDULED, + }), + ); + expect(mockInsertOrderStatusReminder).toHaveBeenCalledWith( + expect.objectContaining({ triggeredAt }), + ); + }); + + it("should swallow reminder insertion errors", async () => { + mockInsertOrderStatusReminder.mockRejectedValueOnce(new Error("Insert failed")); + + await expect( + service.handleOrderStatusUpdated({ + orderId: "550e8400-e29b-41d4-a716-446655440000", + correlationId: "123e4567-e89b-12d3-a456-426614174000", + statusCode: OrderStatusCodes.DISPATCHED, + triggeredAt: "2024-01-15T10:00:00Z", + }), + ).resolves.toBeUndefined(); + + expect(mockInsertOrderStatusReminder).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/src/lib/reminder/order-status-reminder-service.ts b/lambdas/src/lib/reminder/order-status-reminder-service.ts new file mode 100644 index 00000000..aaf3b5a2 --- /dev/null +++ b/lambdas/src/lib/reminder/order-status-reminder-service.ts @@ -0,0 +1,62 @@ +import { ConsoleCommons } from "../commons"; +import { OrderStatusCode, OrderStatusCodes } from "../db/order-status-db"; +import { + OrderStatusReminderDbClient, + OrderStatusReminderStatus, +} from "../db/order-status-reminder-db-client"; + +const commons = new ConsoleCommons(); +const name = "order-status-reminder-service"; + +export interface OrderStatusReminderServiceDependencies { + orderStatusReminderDbClient: OrderStatusReminderDbClient; +} + +export interface HandleOrderStatusReminderInput { + orderId: string; + correlationId: string; + statusCode: OrderStatusCode; + triggeredAt: string; +} + +type ReminderHandlerByStatus = Partial< + Record Promise> +>; + +export class OrderStatusReminderService { + constructor(private readonly dependencies: OrderStatusReminderServiceDependencies) {} + + async handleOrderStatusUpdated(input: HandleOrderStatusReminderInput): Promise { + const { statusCode, correlationId, orderId } = input; + const { orderStatusReminderDbClient } = this.dependencies; + + const handleReminderByStatus: ReminderHandlerByStatus = { + [OrderStatusCodes.DISPATCHED]: async ({ orderId, triggeredAt }) => { + await orderStatusReminderDbClient.insertOrderStatusReminder({ + orderId, + triggerStatus: OrderStatusCodes.DISPATCHED, + reminderNumber: 1, + status: OrderStatusReminderStatus.SCHEDULED, + triggeredAt, + }); + }, + }; + + const handleReminder = handleReminderByStatus[statusCode]; + + if (!handleReminder) { + return; + } + + try { + await handleReminder(input); + } catch (error) { + commons.logError(name, "Failed to schedule order status reminder", { + correlationId, + orderId, + statusCode, + error, + }); + } + } +} diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 81c089ab..8cc9652e 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 mockHandleReminderOrderStatusUpdated = jest.fn(); const mockHandleOrderStatusUpdated = jest.fn(); const mockGetCorrelationIdFromEventHeaders = jest.fn(); @@ -42,6 +43,7 @@ describe("Order Status Lambda Handler", () => { mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); + mockHandleReminderOrderStatusUpdated.mockResolvedValue(undefined); mockHandleOrderStatusUpdated.mockResolvedValue(undefined); mockInit.mockReturnValue({ @@ -50,6 +52,9 @@ describe("Order Status Lambda Handler", () => { checkIdempotency: mockCheckIdempotency, addOrderStatusUpdate: mockAddOrderStatusUpdate, }, + orderStatusReminderService: { + handleOrderStatusUpdated: mockHandleReminderOrderStatusUpdated, + }, orderStatusNotifyService: { handleOrderStatusUpdated: mockHandleOrderStatusUpdated, }, @@ -288,6 +293,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(200); expect(mockCheckIdempotency).toHaveBeenCalledWith(MOCK_ORDER_UID, MOCK_CORRELATION_ID); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); expect(mockHandleOrderStatusUpdated).not.toHaveBeenCalled(); }); @@ -432,6 +438,22 @@ describe("Order Status Lambda Handler", () => { ); }); + it("should delegate reminder scheduling to the reminder service", async () => { + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockHandleReminderOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: MOCK_ORDER_UID, + correlationId: MOCK_CORRELATION_ID, + statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], + triggeredAt: validTaskBody.lastModified, + }), + ); + }); + it("should still delegate non-dispatched statuses to the notification service", async () => { mockEvent.body = JSON.stringify({ ...validTaskBody, @@ -443,6 +465,12 @@ describe("Order Status Lambda Handler", () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); + expect(mockHandleReminderOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + triggeredAt: validTaskBody.lastModified, + }), + ); expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( expect.objectContaining({ statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], @@ -461,6 +489,12 @@ describe("Order Status Lambda Handler", () => { const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); + expect(mockHandleReminderOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: businessStatusMapping[IncomingBusinessStatus.CONFIRMED], + triggeredAt: validTaskBody.lastModified, + }), + ); expect(mockHandleOrderStatusUpdated).toHaveBeenCalledWith( expect.objectContaining({ statusCode: businessStatusMapping[IncomingBusinessStatus.CONFIRMED], @@ -468,6 +502,17 @@ describe("Order Status Lambda Handler", () => { ); }); + it("should return 500 when reminder service fails", async () => { + mockHandleReminderOrderStatusUpdated.mockRejectedValueOnce(new Error("Reminder failed")); + mockEvent.body = JSON.stringify(validTaskBody); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(500); + expect(mockHandleReminderOrderStatusUpdated).toHaveBeenCalledTimes(1); + expect(mockHandleOrderStatusUpdated).not.toHaveBeenCalled(); + }); + it("should return 500 when notification service fails", async () => { mockHandleOrderStatusUpdated.mockRejectedValueOnce(new Error("Unexpected side effect error")); mockEvent.body = JSON.stringify(validTaskBody); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 4cb9a82e..2602f523 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -42,7 +42,7 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb, orderStatusNotifyService } = init(); + const { orderStatusDb, orderStatusReminderService, 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 orderStatusReminderService.handleOrderStatusUpdated({ + orderId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + triggeredAt: statusOrderUpdateParams.createdAt, + }); + await orderStatusNotifyService.handleOrderStatusUpdated({ orderId, patientId: orderPatientId, diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index b858ac2e..7e49c6a6 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -3,9 +3,11 @@ 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 { NotifyMessageBuilder } from "../lib/notify/notify-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/notify-service"; +import { OrderStatusReminderService } from "../lib/reminder/order-status-reminder-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"; @@ -13,6 +15,7 @@ import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environm 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/notification-audit-db-client"); jest.mock("../lib/db/db-client"); @@ -21,6 +24,7 @@ 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/reminder/order-status-reminder-service"); describe("init", () => { const originalEnv = process.env; @@ -99,6 +103,7 @@ describe("init", () => { expect(result).toEqual({ orderStatusDb: expect.any(OrderStatusService), + orderStatusReminderService: expect.any(OrderStatusReminderService), orderStatusNotifyService: expect.any(OrderStatusNotifyService), }); }); @@ -136,6 +141,18 @@ describe("init", () => { times: 1, calledWith: expect.any(PostgresDbClient), }, + { + mock: OrderStatusReminderDbClient as jest.Mock, + times: 1, + calledWith: expect.any(PostgresDbClient), + }, + { + mock: OrderStatusReminderService as jest.Mock, + times: 1, + calledWith: { + orderStatusReminderDbClient: expect.any(OrderStatusReminderDbClient), + }, + }, { mock: PatientDbClient as jest.Mock, times: 1, diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index 7559092d..48d9d2ee 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -3,15 +3,18 @@ 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 { NotifyMessageBuilder } from "../lib/notify/notify-message-builder"; import { OrderStatusNotifyService } from "../lib/notify/notify-service"; +import { OrderStatusReminderService } from "../lib/reminder/order-status-reminder-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 { orderStatusDb: OrderStatusService; + orderStatusReminderService: OrderStatusReminderService; orderStatusNotifyService: OrderStatusNotifyService; } @@ -22,6 +25,10 @@ export function buildEnvironment(): Environment { const secretsClient = new AwsSecretsClient(awsRegion); const dbClient = new PostgresDbClient(postgresConfigFromEnv(secretsClient)); const orderStatusDb = new OrderStatusService(dbClient); + const orderStatusReminderDbClient = new OrderStatusReminderDbClient(dbClient); + const orderStatusReminderService = new OrderStatusReminderService({ + orderStatusReminderDbClient, + }); const patientDbClient = new PatientDbClient(dbClient); const orderDbClient = new OrderDbClient(dbClient); const notificationAuditDbClient = new NotificationAuditDbClient(dbClient); @@ -41,6 +48,7 @@ export function buildEnvironment(): Environment { return { orderStatusDb, + orderStatusReminderService, orderStatusNotifyService, }; }