diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index f3443ed8a..5cd4b6c89 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -35,7 +35,7 @@ No requirements.
| [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no |
| [letter\_event\_source](#input\_letter\_event\_source) | Source value to use for the letter status event updates | `string` | `"/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status"` | no |
| [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no |
-| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, priority = number, billingId = string }))` |
{
"lv1": {
"billingId": "billing1",
"priority": 10,
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"billingId": "billing1",
"priority": 10,
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"billingId": "billing1",
"priority": 10,
"specId": "spec3",
"supplierId": "supplier2"
}
} | no |
+| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, priority = number, billingId = string }))` | {
"digitrials-aspiring": {
"billingId": "digitrials-aspiring-billing",
"priority": "0",
"specId": "digitrials-aspiring",
"supplierId": "supplier1"
},
"digitrials-dmapp": {
"billingId": "notify-admail-billing",
"priority": "1",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"digitrials-globalminds": {
"billingId": "digitrials-globalminds-billing",
"priority": "2",
"specId": "digitrials-globalminds",
"supplierId": "supplier1"
},
"digitrials-mymelanoma": {
"billingId": "digitrials-mymelanoma-billing",
"priority": "3",
"specId": "digitrials-mymelanoma",
"supplierId": "supplier1"
},
"digitrials-ofh": {
"billingId": "digitrials-ofh-billing",
"priority": "4",
"specId": "digitrials-ofh",
"supplierId": "supplier1"
},
"digitrials-prostateprogress": {
"billingId": "digitrials-prostateprogress-billing",
"priority": "5",
"specId": "digitrials-prostateprogress",
"supplierId": "supplier1"
},
"digitrials-protectc": {
"billingId": "notify-c5-colour-billing",
"priority": "6",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
},
"digitrials-restore": {
"billingId": "digitrials-restore-billing",
"priority": "7",
"specId": "digitrials-restore",
"supplierId": "supplier1"
},
"gpreg-admail": {
"billingId": "notify-admail-billing",
"priority": "8",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"nces-abnormal-results": {
"billingId": "nces-abnormal-results-billing",
"priority": "9",
"specId": "nces-abnormal-results",
"supplierId": "supplier1"
},
"nces-abnormal-results-braille": {
"billingId": "nces-abnormal-results-braille-billing",
"priority": "10",
"specId": "nces-abnormal-results-braille",
"supplierId": "supplier1"
},
"nces-invites": {
"billingId": "nces-invites-billing",
"priority": "10",
"specId": "nces-invites",
"supplierId": "supplier1"
},
"nces-invites-braille": {
"billingId": "nces-invites-braille-billing",
"priority": "10",
"specId": "nces-invites-braille",
"supplierId": "supplier1"
},
"nces-standard": {
"billingId": "notify-c5-whitemail-billing",
"priority": "11",
"specId": "notify-c5-whitemail",
"supplierId": "supplier1"
},
"nces-standard-braille": {
"billingId": "notify-braille-whitemail-billing",
"priority": "12",
"specId": "notify-braille-whitemail",
"supplierId": "supplier1"
},
"notify-braille": {
"billingId": "notify-braille-billing",
"priority": "13",
"specId": "notify-braille",
"supplierId": "supplier1"
},
"notify-digital-letters-standard": {
"billingId": "notify-c5-billing",
"priority": "97",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard": {
"billingId": "notify-c5-billing",
"priority": "98",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard-colour": {
"billingId": "notify-c5-colour-billing",
"priority": "99",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
}
} | no |
| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no |
| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no |
| [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no |
diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf
index 508b33d53..d486cedf5 100644
--- a/infrastructure/terraform/components/api/variables.tf
+++ b/infrastructure/terraform/components/api/variables.tf
@@ -138,9 +138,25 @@ variable "eventpub_control_plane_bus_arn" {
variable "letter_variant_map" {
type = map(object({ supplierId = string, specId = string, priority = number, billingId = string }))
default = {
- "lv1" = { supplierId = "supplier1", specId = "spec1", priority = 10, billingId = "billing1" },
- "lv2" = { supplierId = "supplier1", specId = "spec2", priority = 10, billingId = "billing1" },
- "lv3" = { supplierId = "supplier2", specId = "spec3", priority = 10, billingId = "billing1" }
+ "digitrials-aspiring" = { supplierId = "supplier1", specId = "digitrials-aspiring", priority = "0", billingId = "digitrials-aspiring-billing" },
+ "digitrials-dmapp" = { supplierId = "supplier1", specId = "notify-admail", priority = "1", billingId = "notify-admail-billing" },
+ "digitrials-globalminds" = { supplierId = "supplier1", specId = "digitrials-globalminds", priority = "2", billingId = "digitrials-globalminds-billing" },
+ "digitrials-mymelanoma" = { supplierId = "supplier1", specId = "digitrials-mymelanoma", priority = "3", billingId = "digitrials-mymelanoma-billing" },
+ "digitrials-ofh" = { supplierId = "supplier1", specId = "digitrials-ofh", priority = "4", billingId = "digitrials-ofh-billing" },
+ "digitrials-prostateprogress" = { supplierId = "supplier1", specId = "digitrials-prostateprogress", priority = "5", billingId = "digitrials-prostateprogress-billing" },
+ "digitrials-protectc" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "6", billingId = "notify-c5-colour-billing" },
+ "digitrials-restore" = { supplierId = "supplier1", specId = "digitrials-restore", priority = "7", billingId = "digitrials-restore-billing" },
+ "gpreg-admail" = { supplierId = "supplier1", specId = "notify-admail", priority = "8", billingId = "notify-admail-billing" },
+ "nces-abnormal-results" = { supplierId = "supplier1", specId = "nces-abnormal-results", priority = "9", billingId = "nces-abnormal-results-billing" },
+ "nces-abnormal-results-braille" = { supplierId = "supplier1", specId = "nces-abnormal-results-braille", priority = "10", billingId = "nces-abnormal-results-braille-billing" },
+ "nces-invites" = { supplierId = "supplier1", specId = "nces-invites", priority = "10", billingId = "nces-invites-billing" },
+ "nces-invites-braille" = { supplierId = "supplier1", specId = "nces-invites-braille", priority = "10", billingId = "nces-invites-braille-billing" },
+ "nces-standard" = { supplierId = "supplier1", specId = "notify-c5-whitemail", priority = "11", billingId = "notify-c5-whitemail-billing" },
+ "nces-standard-braille" = { supplierId = "supplier1", specId = "notify-braille-whitemail", priority = "12", billingId = "notify-braille-whitemail-billing" },
+ "notify-braille" = { supplierId = "supplier1", specId = "notify-braille", priority = "13", billingId = "notify-braille-billing" },
+ "notify-digital-letters-standard" = { supplierId = "supplier1", specId = "notify-c5", priority = "97", billingId = "notify-c5-billing" },
+ "notify-standard" = { supplierId = "supplier1", specId = "notify-c5", priority = "98", billingId = "notify-c5-billing" },
+ "notify-standard-colour" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "99", billingId = "notify-c5-colour-billing" }
}
}
diff --git a/internal/event-builders/src/__tests__/letter-mapper.test.ts b/internal/event-builders/src/__tests__/letter-mapper.test.ts
index b35b05363..8d799f10b 100644
--- a/internal/event-builders/src/__tests__/letter-mapper.test.ts
+++ b/internal/event-builders/src/__tests__/letter-mapper.test.ts
@@ -1,4 +1,4 @@
-import { $LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
+import { $LetterStatusChangeEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { Letter } from "@internal/datastore";
import { mapLetterToCloudEvent } from "../letter-mapper";
@@ -22,7 +22,7 @@ describe("letter-mapper", () => {
const event = mapLetterToCloudEvent(letter, source);
// Check it conforms to the letter event schema - parse will throw an error if not
- $LetterEvent.parse(event);
+ $LetterStatusChangeEvent.parse(event);
expect(event.type).toBe("uk.nhs.notify.supplier-api.letter.PRINTED.v1");
expect(event.dataschema).toBe(
`https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.PRINTED.${event.dataschemaversion}.schema.json`,
diff --git a/internal/event-builders/src/letter-mapper.ts b/internal/event-builders/src/letter-mapper.ts
index 029e3284d..27083b1b3 100644
--- a/internal/event-builders/src/letter-mapper.ts
+++ b/internal/event-builders/src/letter-mapper.ts
@@ -1,13 +1,13 @@
import { randomBytes, randomUUID } from "node:crypto";
import eventSchemaPackage from "@nhsdigital/nhs-notify-event-schemas-supplier-api/package.json";
import { Letter } from "@internal/datastore";
-import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
+import { LetterStatusChangeEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
// eslint-disable-next-line import-x/prefer-default-export
export function mapLetterToCloudEvent(
letter: Letter,
source: string,
-): LetterEvent {
+): LetterStatusChangeEvent {
const eventId = randomUUID();
const dataschemaversion = eventSchemaPackage.version;
return {
@@ -21,7 +21,7 @@ export function mapLetterToCloudEvent(
subject: `letter-origin/letter-rendering/letter/${letter.id}`,
data: {
- domainId: letter.id as LetterEvent["data"]["domainId"],
+ domainId: letter.id as LetterStatusChangeEvent["data"]["domainId"],
status: letter.status,
specificationId: letter.specificationId,
billingRef: letter.billingRef,
diff --git a/internal/events/package.json b/internal/events/package.json
index 74fe945c3..4e71cdcf3 100644
--- a/internal/events/package.json
+++ b/internal/events/package.json
@@ -36,5 +36,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
- "version": "1.0.17"
+ "version": "1.0.18"
}
diff --git a/internal/events/src/cli/generate-json.ts b/internal/events/src/cli/generate-json.ts
index a6d35d68f..cc755d571 100644
--- a/internal/events/src/cli/generate-json.ts
+++ b/internal/events/src/cli/generate-json.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import * as fs from "node:fs";
import { $Letter } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/domain/letter";
import {
- $LetterEvent,
+ $LetterStatusChangeEvent,
letterEventMap,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
import { $MISubmittedEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/mi-events";
@@ -36,7 +36,7 @@ for (const [key, schema] of Object.entries(letterEventMap)) {
}
// Generic letter status change event schema
-const json = z.toJSONSchema($LetterEvent, {
+const json = z.toJSONSchema($LetterStatusChangeEvent, {
io: "input",
target: "openapi-3.0",
reused: "ref",
diff --git a/internal/events/src/events/letter-events.ts b/internal/events/src/events/letter-events.ts
index 497950687..f4afd5292 100644
--- a/internal/events/src/events/letter-events.ts
+++ b/internal/events/src/events/letter-events.ts
@@ -9,7 +9,7 @@ import { EventEnvelope } from "@nhsdigital/nhs-notify-event-schemas-supplier-api
/**
* A generic schema for parsing any letter status change event
*/
-export const $LetterEvent = EventEnvelope(
+export const $LetterStatusChangeEvent = EventEnvelope(
"letter",
"letter",
$Letter,
@@ -19,7 +19,7 @@ export const $LetterEvent = EventEnvelope(
title: `letter.* Event`,
description: `Event schema for generic letter status change`,
});
-export type LetterEvent = z.infer;
+export type LetterStatusChangeEvent = z.infer;
/**
* Specialise the generic event schema for a single status
diff --git a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
index 164a22e64..aa3e981dd 100644
--- a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
+++ b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
@@ -1,6 +1,6 @@
import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda";
import { PublishCommand } from "@aws-sdk/client-sns";
-import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
+import { LetterStatusChangeEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers";
import { mapLetterToCloudEvent } from "@internal/event-builders/src";
import { Unit } from "aws-embedded-metrics";
@@ -66,7 +66,7 @@ export default function createTransformAmendmentEventHandler(
}
function buildSnsCommand(
- letterEvent: LetterEvent,
+ letterEvent: LetterStatusChangeEvent,
topicArn: string,
): PublishCommand {
return new PublishCommand({
diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
index d9705af54..efe30ee61 100644
--- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
+++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
@@ -9,7 +9,7 @@ import {
PublishBatchCommand,
PublishBatchRequestEntry,
} from "@aws-sdk/client-sns";
-import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
+import { LetterStatusChangeEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { MetricEntry, buildEMFObject } from "@internal/helpers";
import { Letter, LetterSchema } from "@internal/datastore";
import { mapLetterToCloudEvent } from "@internal/event-builders/src";
@@ -33,7 +33,7 @@ export default function createHandler(deps: Deps): Handler {
extractPayload(record, deps),
);
- const cloudEvents: LetterEvent[] = ddbRecords
+ const cloudEvents: LetterStatusChangeEvent[] = ddbRecords
.filter((record) => filterRecord(record, deps))
.map((element) => extractNewLetter(element))
.map((element) => mapLetterToCloudEvent(element, deps.env.EVENT_SOURCE));
@@ -59,7 +59,7 @@ export default function createHandler(deps: Deps): Handler {
};
}
-function populateEventTypeMap(cloudEvents: LetterEvent[]) {
+function populateEventTypeMap(cloudEvents: LetterStatusChangeEvent[]) {
const evtMap = new Map();
for (const event of cloudEvents) {
evtMap.set(event.type, (evtMap.get(event.type) || 0) + 1);
@@ -142,14 +142,14 @@ function extractNewLetter(record: DynamoDBRecord): Letter {
return LetterSchema.parse(unmarshall(newImage as any));
}
-function* generateBatches(events: LetterEvent[]) {
+function* generateBatches(events: LetterStatusChangeEvent[]) {
for (let i = 0; i < events.length; i += BATCH_SIZE) {
yield events.slice(i, i + BATCH_SIZE);
}
}
function buildMessage(
- event: LetterEvent,
+ event: LetterStatusChangeEvent,
index: number,
): PublishBatchRequestEntry {
return {
diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
index 2cea9f7ed..eb1a3bfdb 100644
--- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
+++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
@@ -4,8 +4,8 @@ import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
import {
- $LetterEvent,
- LetterEvent,
+ $LetterStatusChangeEvent,
+ LetterStatusChangeEvent,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
import { SupplierConfigRepository } from "@internal/datastore";
import createSupplierAllocatorHandler from "../allocate-handler";
@@ -99,10 +99,10 @@ function createPreparedV2Event(
function createSupplierStatusChangeEvent(
overrides: Partial = {},
-): LetterEvent {
+): LetterStatusChangeEvent {
const now = new Date().toISOString();
- return $LetterEvent.parse({
+ return $LetterStatusChangeEvent.parse({
data: {
domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479",
groupId: "client_template",
diff --git a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
index 9355927fd..2ead57328 100644
--- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
+++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
@@ -7,8 +7,8 @@ import {
import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
import {
- $LetterEvent,
- LetterEvent,
+ $LetterStatusChangeEvent,
+ LetterStatusChangeEvent,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
import createUpsertLetterHandler from "../upsert-handler";
import { Deps } from "../../config/deps";
@@ -86,10 +86,10 @@ function createPreparedV1Event(
function createSupplierStatusChangeEventWithoutSupplier(
overrides: Partial = {},
-): LetterEvent {
+): LetterStatusChangeEvent {
const now = new Date().toISOString();
- return $LetterEvent.parse({
+ return $LetterStatusChangeEvent.parse({
data: {
domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479",
groupId: "client_template",
@@ -140,10 +140,10 @@ function createPreparedV2Event(
function createSupplierStatusChangeEvent(
overrides: Partial = {},
-): LetterEvent {
+): LetterStatusChangeEvent {
const now = new Date().toISOString();
- return $LetterEvent.parse({
+ return $LetterStatusChangeEvent.parse({
data: {
domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479",
groupId: "client_template",
diff --git a/lambdas/upsert-letter/src/handler/schemas.ts b/lambdas/upsert-letter/src/handler/schemas.ts
new file mode 100644
index 000000000..c4e029bc6
--- /dev/null
+++ b/lambdas/upsert-letter/src/handler/schemas.ts
@@ -0,0 +1,53 @@
+import {
+ $LetterRequestPreparedEvent,
+ LetterRequestPreparedEvent,
+} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
+import { $LetterStatusChangeEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
+import {
+ $LetterRequestPreparedEventV2,
+ LetterRequestPreparedEventV2,
+} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
+import z from "zod";
+import { Deps } from "../config/deps";
+
+export type PreparedEvents =
+ | LetterRequestPreparedEventV2
+ | LetterRequestPreparedEvent;
+
+const SupplierSpecSchema = z.object({
+ supplierId: z.string().min(1),
+ specId: z.string().min(1),
+ priority: z.int().min(0).max(99).default(10),
+ billingId: z.string().min(1),
+});
+
+export type SupplierSpec = z.infer;
+
+export const PreparedEventUnionSchema = z.discriminatedUnion("type", [
+ $LetterRequestPreparedEventV2,
+ $LetterRequestPreparedEvent,
+]);
+
+export const AllocatedLetterSchema = z.object({
+ letterEvent: PreparedEventUnionSchema,
+ supplierSpec: SupplierSpecSchema,
+});
+
+export type AllocatedLetter = z.infer;
+
+export const QueueMessageSchema = z.union([
+ $LetterStatusChangeEvent,
+ AllocatedLetterSchema,
+]);
+
+export type QueueMessage = z.infer;
+
+export type UpsertOperation = {
+ name: "Insert" | "Update";
+ schemas: z.ZodSchema[];
+ handler: (
+ request: unknown,
+ supplierSpec: SupplierSpec,
+ deps: Deps,
+ ) => Promise;
+};
diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts
index 684b53da7..677048ba5 100644
--- a/lambdas/upsert-letter/src/handler/upsert-handler.ts
+++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts
@@ -1,60 +1,24 @@
import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda";
+import { $LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
import {
InsertLetter,
LetterAlreadyExistsError,
UpdateLetter,
} from "@internal/datastore";
import {
- $LetterRequestPreparedEvent,
- LetterRequestPreparedEvent,
-} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
-import {
- $LetterEvent,
- LetterEvent,
+ $LetterStatusChangeEvent,
+ LetterStatusChangeEvent,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
-import {
- $LetterRequestPreparedEventV2,
- LetterRequestPreparedEventV2,
-} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
-import z from "zod";
+import { $LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics";
import { Deps } from "../config/deps";
-
-type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent;
-
-const SupplierSpecSchema = z.object({
- supplierId: z.string().min(1),
- specId: z.string().min(1),
- priority: z.int().min(0).max(99).default(10),
- billingId: z.string().min(1),
-});
-
-type SupplierSpec = z.infer;
-
-const PreparedEventUnionSchema = z.discriminatedUnion("type", [
- $LetterRequestPreparedEventV2,
- $LetterRequestPreparedEvent,
-]);
-
-const QueueMessageSchema = z.union([
- $LetterEvent,
- z.object({
- letterEvent: PreparedEventUnionSchema,
- supplierSpec: SupplierSpecSchema,
- }),
-]);
-
-type QueueMessage = z.infer;
-
-type UpsertOperation = {
- name: "Insert" | "Update";
- schemas: z.ZodSchema[];
- handler: (
- request: unknown,
- supplierSpec: SupplierSpec,
- deps: Deps,
- ) => Promise;
-};
+import {
+ PreparedEvents,
+ QueueMessage,
+ QueueMessageSchema,
+ SupplierSpec,
+ UpsertOperation,
+} from "./schemas";
function getOperationFromType(type: string): UpsertOperation {
if (
@@ -99,9 +63,9 @@ function getOperationFromType(type: string): UpsertOperation {
// if it's not an insert type, it must be an update as we've already parsed the message, but we want to have a separate operation for better logging and metrics
return {
name: "Update",
- schemas: [$LetterEvent],
+ schemas: [$LetterStatusChangeEvent],
handler: async (request, supplierSpec, deps) => {
- const supplierEvent = request as LetterEvent;
+ const supplierEvent = request as LetterStatusChangeEvent;
const letterToUpdate: UpdateLetter = mapToUpdateLetter(supplierEvent);
await deps.letterRepo.updateLetterStatus(letterToUpdate);
@@ -145,7 +109,9 @@ function mapToInsertLetter(
};
}
-function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter {
+function mapToUpdateLetter(
+ upsertRequest: LetterStatusChangeEvent,
+): UpdateLetter {
return {
id: upsertRequest.data.domainId,
eventId: upsertRequest.id,
@@ -232,7 +198,7 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler {
const queueMessage: QueueMessage = parseQueueMessage(sqsMessage);
- let letterEvent: LetterEvent | PreparedEvents;
+ let letterEvent: LetterStatusChangeEvent | PreparedEvents;
let supplierSpec: SupplierSpec | undefined;
if ("letterEvent" in queueMessage) {
diff --git a/scripts/config/markdownlint.yaml b/scripts/config/markdownlint.yaml
new file mode 100644
index 000000000..554ab554b
--- /dev/null
+++ b/scripts/config/markdownlint.yaml
@@ -0,0 +1,11 @@
+# SEE: https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml
+
+# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
+MD013: false
+
+# https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md
+MD024:
+ siblings_only: true
+
+# https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md
+MD033: false
diff --git a/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts b/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts
new file mode 100644
index 000000000..59b008187
--- /dev/null
+++ b/tests/component-tests/integration-tests/urgent-letter-priority.spec.ts
@@ -0,0 +1,154 @@
+import { expect, test } from "@playwright/test";
+import { sendSnsEvent } from "tests/helpers/send-sns-event";
+import { createPreparedV1Event } from "tests/helpers/event-fixtures";
+import { randomUUID } from "node:crypto";
+import { logger } from "tests/helpers/pino-logger";
+import { createValidRequestHeaders } from "tests/constants/request-headers";
+import getRestApiGatewayBaseUrl from "tests/helpers/aws-gateway-helper";
+import { SUPPLIER_LETTERS } from "tests/constants/api-constants";
+import { pollForLetterStatus } from "tests/helpers/poll-for-letters-helper";
+import { pollSupplierAllocatorLogForResolvedSpec } from "tests/helpers/aws-cloudwatch-helper";
+import {
+ GetLettersResponse,
+ GetLettersResponseSchema,
+} from "../../../lambdas/api-handler/src/contracts/letters";
+import {
+ AllocatedLetter,
+ AllocatedLetterSchema,
+} from "../../../lambdas/upsert-letter/src/handler/schemas";
+
+// See group_nhs-notify-supplier-api-dev.tfvars in nhs-notify-internal
+const variantUrgencyMap: Record = {
+ "digitrials-aspiring": 0,
+ "digitrials-dmapp": 1,
+ "digitrials-globalminds": 2,
+ "digitrials-mymelanoma": 3,
+ "digitrials-ofh": 4,
+ "digitrials-prostateprogress": 5,
+ "digitrials-protectc": 6,
+ "digitrials-restore": 7,
+ "gpreg-admail": 8,
+ "nces-abnormal-results": 9,
+ "nces-abnormal-results-braille": 10,
+ "nces-invites": 10,
+ "nces-invites-braille": 10,
+ "nces-standard": 11,
+ "nces-standard-braille": 12,
+ "notify-braille": 13,
+ "notify-digital-letters-standard": 97,
+ "notify-standard": 98,
+ "notify-standard-colour": 99,
+};
+
+// See group_nhs-notify-supplier-api-dev.tfvars in nhs-notify-internal
+const supplier = "supplier1";
+
+let baseUrl: string;
+
+test.beforeAll(async () => {
+ baseUrl = await getRestApiGatewayBaseUrl();
+});
+
+test.describe("Urgent Letter Priority Tests", () => {
+ test.setTimeout(180_000); // 3 minutes for long running polling
+
+ test("Letter with higher urgency gets picked first", async ({ request }) => {
+ const variantsUrgencyNine = getVariantsWithUrgency(9);
+ const urgencyNineLetterIds =
+ await sendEventsForVariants(variantsUrgencyNine);
+ const variantsUrgencyTen = getVariantsWithUrgency(10);
+ const urgencyTenLetterIds = await sendEventsForVariants(variantsUrgencyTen);
+
+ await Promise.all(
+ [...urgencyNineLetterIds, ...urgencyTenLetterIds].map(async (domainId) =>
+ pollForLetterStatus(request, supplier, domainId, baseUrl),
+ ),
+ );
+
+ await verifyAllocationLogsContainPriority(urgencyNineLetterIds, 9);
+ await verifyAllocationLogsContainPriority(urgencyTenLetterIds, 10);
+
+ // TODO: CCM-15185 should call the endpoint directly to verify the order of letters
+ // const header = createValidRequestHeaders(supplier);
+ // const response = await request.get(`${baseUrl}/${SUPPLIER_LETTERS}`, {
+ // headers: header,
+ // });
+
+ // expect(response.status()).toBe(200);
+ // const responseBody = await response.json();
+ // expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
+
+ // const getLettersResponse: GetLettersResponse =
+ // GetLettersResponseSchema.parse(responseBody);
+
+ // verifyIndexPositionOfLetterVariants(
+ // getLettersResponse,
+ // urgencyNineLetterIds,
+ // urgencyTenLetterIds,
+ // );
+ });
+});
+
+function getVariantsWithUrgency(urgency: number) {
+ const variants = Object.keys(variantUrgencyMap).filter(
+ // eslint-disable-next-line security/detect-object-injection
+ // safe has comes from map's keys which are controlled by us
+ (variant) => variantUrgencyMap[variant] === urgency,
+ );
+ if (variants.length === 0) {
+ throw new Error(`No variants found with urgency ${urgency}`);
+ }
+ return variants;
+}
+
+async function sendEventsForVariants(variants: string[]) {
+ const domainIds: string[] = [];
+ for (const variant of variants) {
+ const domainId = randomUUID();
+ logger.info(
+ `Testing event subscription with domainId: ${domainId} and variant: ${variant}`,
+ );
+ const preparedEvent = createPreparedV1Event({
+ domainId,
+ letterVariantId: variant,
+ });
+ const response = await sendSnsEvent(preparedEvent);
+ expect(response.MessageId).toBeTruthy();
+ domainIds.push(domainId);
+ }
+ return domainIds;
+}
+
+function verifyIndexPositionOfLetterVariants(
+ getLettersResponse: GetLettersResponse,
+ letterIdsLeastUrgency: string[],
+ letterIdsHigherUrgency: string[],
+) {
+ const letterIds = getLettersResponse.data.map((letter) => letter.id);
+ for (const leastUrgencyLetterId of letterIdsLeastUrgency) {
+ expect(letterIds).toContain(leastUrgencyLetterId); // in case limit param is hit
+ const indexToTest = letterIds.indexOf(leastUrgencyLetterId);
+ for (const higherUrgencyLetterId of letterIdsHigherUrgency) {
+ expect(letterIds).toContain(higherUrgencyLetterId); // in case limit param is hit
+ const higherUrgencyIndex = letterIds.indexOf(higherUrgencyLetterId);
+ expect(indexToTest).toBeLessThan(higherUrgencyIndex); // higher urgency letters should come before lower urgency letters
+ }
+ }
+}
+
+async function verifyAllocationLogsContainPriority(
+ letterIds: string[],
+ priority: number,
+) {
+ for (const domainId of letterIds) {
+ const message = await pollSupplierAllocatorLogForResolvedSpec(domainId);
+ const supplierAllocatorLog = JSON.parse(message);
+ const allocatedLetter: AllocatedLetter = AllocatedLetterSchema.parse(
+ supplierAllocatorLog.msg,
+ );
+ const { supplierSpec } = allocatedLetter;
+ expect(supplierSpec).toBeDefined();
+ expect(supplierSpec.priority).toBeDefined();
+ expect(supplierSpec.priority).toBe(priority);
+ }
+}
diff --git a/tests/config/main.config.ts b/tests/config/main.config.ts
index 68e1352b8..3a371f324 100644
--- a/tests/config/main.config.ts
+++ b/tests/config/main.config.ts
@@ -25,6 +25,11 @@ const localConfig: PlaywrightTestConfig = {
testDir: path.resolve(__dirname, "../component-tests/letterQueue-tests"),
testMatch: "**/*.spec.ts",
},
+ {
+ name: "integration-tests",
+ testDir: path.resolve(__dirname, "../component-tests/integration-tests"),
+ testMatch: "**/*.spec.ts",
+ },
],
};
diff --git a/tests/helpers/event-fixtures.ts b/tests/helpers/event-fixtures.ts
index ab6552aaf..5862cca20 100644
--- a/tests/helpers/event-fixtures.ts
+++ b/tests/helpers/event-fixtures.ts
@@ -17,7 +17,8 @@ export function createPreparedV1Event(overrides: Record = {}) {
domainId:
(overrides.domainId as string) ??
"fe658e11-0ffc-44f4-8ad6-0fafe75bfeee",
- letterVariantId: "digitrials-aspiring",
+ letterVariantId:
+ (overrides.letterVariantId as string) ?? "digitrials-aspiring",
requestId: "request1",
requestItemId: "requestItem1",
requestItemPlanId: "requestItemPlan1",
diff --git a/tests/helpers/poll-for-letters-helper.ts b/tests/helpers/poll-for-letters-helper.ts
index eb080b7bd..a67d2b37f 100644
--- a/tests/helpers/poll-for-letters-helper.ts
+++ b/tests/helpers/poll-for-letters-helper.ts
@@ -13,7 +13,7 @@ export async function pollForLetterStatus(
let statusCode = 0;
let letterStatus: string | undefined;
const RETRY_DELAY_MS = 10_000;
- const MAX_ATTEMPTS = 5;
+ const MAX_ATTEMPTS = 6;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const getLetterResponse = await request.get(
@@ -33,7 +33,7 @@ export async function pollForLetterStatus(
logger.info(
`Attempt ${attempt}: Received status code ${statusCode} for domainId: ${domainId}`,
);
- break;
+ return { letterStatus, statusCode };
}
if (attempt < MAX_ATTEMPTS) {
@@ -46,5 +46,7 @@ export async function pollForLetterStatus(
}
}
- return { letterStatus, statusCode };
+ throw new Error(
+ `Max attempts reached while polling for letter status for domainId: ${domainId}`,
+ );
}