Skip to content
2 changes: 1 addition & 1 deletion lambdas/src/lib/db/notification-audit-db-client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
176 changes: 176 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,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<Pick<DBClient, "query" | "withTransaction" | "close">>;
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,
]);
});
});
});
102 changes: 102 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,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<OrderStatusReminderRecord[]> {
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<void> {
const query = `
UPDATE order_status_reminder
SET status = 'QUEUED', sent_at = NOW()
WHERE reminder_id = $1::uuid
`;

await this.dbClient.query<void, [string]>(query, [reminderId]);
}

async markReminderAsFailed(reminderId: string): Promise<void> {
const query = `
UPDATE order_status_reminder
SET status = 'FAILED'
WHERE reminder_id = $1::uuid
`;

await this.dbClient.query<void, [string]>(query, [reminderId]);
}

async scheduleReminder(
orderUid: string,
triggerStatus: string,
reminderNumber: number,
triggeredAt: Date,
): Promise<void> {
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<void, [string, string, number, Date]>(query, [
orderUid,
triggerStatus,
reminderNumber,
triggeredAt,
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { v4 as uuidv4 } from "uuid";

import type { OrderDbClient } from "../../db/order-db-client";
import type { PatientDbClient } from "../../db/patient-db-client";
import type { NotifyEventCode, NotifyMessage, NotifyRecipient } from "../../types/notify-message";

export interface NotifyMessageBuilder<TInput> {
build(input: TInput): Promise<NotifyMessage>;
}

export interface NotifyMessageBuilderDependencies {
patientDbClient: PatientDbClient;
orderDbClient: OrderDbClient;
homeTestBaseUrl: string;
}

export abstract class BaseNotifyMessageBuilder<TInput> implements NotifyMessageBuilder<TInput> {
private readonly normalizedHomeTestBaseUrl: string;

constructor(protected readonly deps: NotifyMessageBuilderDependencies) {
this.normalizedHomeTestBaseUrl = deps.homeTestBaseUrl.replaceAll(/\/$/g, "");
}

abstract build(input: TInput): Promise<NotifyMessage>;

protected async getRecipient(patientId: string): Promise<NotifyRecipient> {
const patient = await this.deps.patientDbClient.get(patientId);
return { nhsNumber: patient.nhsNumber, dateOfBirth: patient.birthDate };
}

protected async getReferenceNumber(orderId: string): Promise<string> {
return this.deps.orderDbClient.getOrderReferenceNumber(orderId);
}

protected buildTrackingUrl(orderId: string): string {
return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/tracking`;
}

protected buildResultsUrl(orderId: string): string {
return `${this.normalizedHomeTestBaseUrl}/orders/${orderId}/results`;
}

protected buildMessage(params: {
correlationId: string;
eventCode: NotifyEventCode;
recipient: NotifyRecipient;
personalisation: Record<string, string>;
messageReference?: string;
}): NotifyMessage {
return {
correlationId: params.correlationId,
messageReference: params.messageReference ?? uuidv4(),
eventCode: params.eventCode,
recipient: params.recipient,
personalisation: params.personalisation,
};
}

protected formatStatusDate(isoDateTime: string): string {
return new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(isoDateTime));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NotifyEventCode, type NotifyMessage } from "../../../types/notify-message";
import {
BaseNotifyMessageBuilder,
type NotifyMessageBuilderDependencies,
} from "../base-notify-message-builder";
import { type OrderStatusNotifyMessageBuilderInput } from "./order-status-notify-message-builder";

export class OrderConfirmedMessageBuilder extends BaseNotifyMessageBuilder<OrderStatusNotifyMessageBuilderInput> {
constructor(deps: NotifyMessageBuilderDependencies) {
super(deps);
}

async build(input: OrderStatusNotifyMessageBuilderInput): Promise<NotifyMessage> {
const { patientId, orderId, correlationId } = input;

const [recipient, referenceNumber, orderCreatedAt] = await Promise.all([
this.getRecipient(patientId),
this.getReferenceNumber(orderId),
this.deps.orderDbClient.getOrderCreatedAt(orderId),
]);

return this.buildMessage({
correlationId,
eventCode: NotifyEventCode.OrderConfirmed,
recipient,
personalisation: {
orderedDate: this.formatStatusDate(orderCreatedAt),
orderLinkUrl: this.buildTrackingUrl(orderId),
referenceNumber,
},
});
}
}
Loading
Loading