Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions lambdas/src/lib/db/order-status-reminder-db-client.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
54 changes: 54 additions & 0 deletions lambdas/src/lib/db/order-status-reminder-db-client.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
});
}
}
}
70 changes: 70 additions & 0 deletions lambdas/src/lib/reminder/order-status-reminder-service.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
62 changes: 62 additions & 0 deletions lambdas/src/lib/reminder/order-status-reminder-service.ts
Original file line number Diff line number Diff line change
@@ -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<OrderStatusCode, (input: HandleOrderStatusReminderInput) => Promise<void>>
>;

export class OrderStatusReminderService {
constructor(private readonly dependencies: OrderStatusReminderServiceDependencies) {}

async handleOrderStatusUpdated(input: HandleOrderStatusReminderInput): Promise<void> {
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,
});
}
}
}
45 changes: 45 additions & 0 deletions lambdas/src/order-status-lambda/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand All @@ -50,6 +52,9 @@ describe("Order Status Lambda Handler", () => {
checkIdempotency: mockCheckIdempotency,
addOrderStatusUpdate: mockAddOrderStatusUpdate,
},
orderStatusReminderService: {
handleOrderStatusUpdated: mockHandleReminderOrderStatusUpdated,
},
orderStatusNotifyService: {
handleOrderStatusUpdated: mockHandleOrderStatusUpdated,
},
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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,
Expand All @@ -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],
Expand All @@ -461,13 +489,30 @@ 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],
}),
);
});

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);
Expand Down
9 changes: 8 additions & 1 deletion lambdas/src/order-status-lambda/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type OrderStatusFHIRTask = z.infer<typeof orderStatusFHIRTaskSchema>;
export const lambdaHandler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const { orderStatusDb, orderStatusNotifyService } = init();
const { orderStatusDb, orderStatusReminderService, orderStatusNotifyService } = init();
commons.logInfo(name, "Received order status update request", {
path: event.path,
method: event.httpMethod,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading