diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index 75faa887f..c346bf431 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -43,6 +43,8 @@ No requirements.
| Name | Source | Version |
|------|--------|---------|
+| [amendment\_event\_transformer](#module\_amendment\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
+| [amendments\_queue](#module\_amendments\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
| [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.31/terraform-eventpub.zip | n/a |
@@ -52,7 +54,6 @@ No requirements.
| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a |
-| [letter\_status\_update](#module\_letter\_status\_update) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
diff --git a/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
index ab3634c43..71ecd8085 100644
--- a/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
+++ b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
@@ -1,12 +1,12 @@
resource "aws_lambda_event_source_mapping" "status_updates_sqs_to_status_update_handler" {
- event_source_arn = module.letter_status_updates_queue.sqs_queue_arn
- function_name = module.letter_status_update.function_arn
+ event_source_arn = module.amendments_queue.sqs_queue_arn
+ function_name = module.amendment_event_transformer.function_arn
batch_size = 10
maximum_batching_window_in_seconds = 1
scaling_config { maximum_concurrency = 10 }
depends_on = [
- module.letter_status_updates_queue, # ensures queue exists
- module.letter_status_update # ensures update handler exists
+ module.amendments_queue, # ensures queue exists
+ module.amendment_event_transformer # ensures update handler exists
]
}
diff --git a/infrastructure/terraform/components/api/lambda_event_source_mapping_amendments.tf b/infrastructure/terraform/components/api/lambda_event_source_mapping_amendments.tf
new file mode 100644
index 000000000..ffbf4bf8e
--- /dev/null
+++ b/infrastructure/terraform/components/api/lambda_event_source_mapping_amendments.tf
@@ -0,0 +1,9 @@
+resource "aws_lambda_event_source_mapping" "amendment_event_transformer" {
+ event_source_arn = module.amendments_queue.sqs_queue_arn
+ function_name = module.amendment_event_transformer.function_name
+ batch_size = 10
+ maximum_batching_window_in_seconds = 5
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf b/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf
new file mode 100644
index 000000000..5813b8e3d
--- /dev/null
+++ b/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf
@@ -0,0 +1,9 @@
+resource "aws_lambda_event_source_mapping" "letter_status_update" {
+ event_source_arn = module.letter_status_updates_queue.sqs_queue_arn
+ function_name = module.amendment_event_transformer.function_name
+ batch_size = 10
+ maximum_batching_window_in_seconds = 5
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 72ae70ba6..46c7aca72 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -27,6 +27,8 @@ locals {
SUPPLIER_ID_HEADER = "nhsd-supplier-id",
APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
+ SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
+ EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
}
core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline"
diff --git a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf b/infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
similarity index 76%
rename from infrastructure/terraform/components/api/module_lambda_letter_status_update.tf
rename to infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
index b9d97fec5..d26d6d4f6 100644
--- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf
+++ b/infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
@@ -1,7 +1,7 @@
-module "letter_status_update" {
+module "amendment_event_transformer" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
- function_name = "letter_status_update"
+ function_name = "amendment_event_transformer"
description = "Processes letter status updates"
aws_account_id = var.aws_account_id
@@ -15,14 +15,14 @@ module "letter_status_update" {
kms_key_arn = module.kms.key_arn
iam_policy_document = {
- body = data.aws_iam_policy_document.letter_status_update.json
+ body = data.aws_iam_policy_document.amendment_event_transformer.json
}
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
function_code_base_path = local.aws_lambda_functions_dir_path
function_code_dir = "api-handler/dist"
function_include_common = true
- handler_function_name = "letterStatusUpdate"
+ handler_function_name = "transformAmendmentEvent"
runtime = "nodejs22.x"
memory = 512
timeout = 29
@@ -37,7 +37,7 @@ module "letter_status_update" {
lambda_env_vars = merge(local.common_lambda_env_vars, {})
}
-data "aws_iam_policy_document" "letter_status_update" {
+data "aws_iam_policy_document" "amendment_event_transformer" {
statement {
sid = "KMSPermissions"
effect = "Allow"
@@ -59,7 +59,6 @@ data "aws_iam_policy_document" "letter_status_update" {
actions = [
"dynamodb:GetItem",
"dynamodb:Query",
- "dynamodb:UpdateItem",
]
resources = [
@@ -79,7 +78,21 @@ data "aws_iam_policy_document" "letter_status_update" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.letter_status_updates_queue.sqs_queue_arn,
+ module.amendments_queue.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowSNSPublish"
+ effect = "Allow"
+
+ actions = [
+ "sns:Publish"
+ ]
+
+ resources = [
+ module.eventsub.sns_topic.arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
index d10e8f240..0298e0887 100644
--- a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
+++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
@@ -35,7 +35,7 @@ module "patch_letter" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = merge(local.common_lambda_env_vars, {
- QUEUE_URL = module.letter_status_updates_queue.sqs_queue_url
+ QUEUE_URL = module.amendments_queue.sqs_queue_url
})
}
@@ -64,7 +64,7 @@ data "aws_iam_policy_document" "patch_letter_lambda" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.amendments_queue.sqs_queue_arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_post_letters.tf b/infrastructure/terraform/components/api/module_lambda_post_letters.tf
index 79b3b3f0b..55865da34 100644
--- a/infrastructure/terraform/components/api/module_lambda_post_letters.tf
+++ b/infrastructure/terraform/components/api/module_lambda_post_letters.tf
@@ -35,7 +35,7 @@ module "post_letters" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = merge(local.common_lambda_env_vars, {
- QUEUE_URL = module.letter_status_updates_queue.sqs_queue_url,
+ QUEUE_URL = module.amendments_queue.sqs_queue_url,
MAX_LIMIT = var.max_get_limit
})
}
@@ -65,7 +65,7 @@ data "aws_iam_policy_document" "post_letters" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.amendments_queue.sqs_queue_arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_sqs_amendments.tf b/infrastructure/terraform/components/api/module_sqs_amendments.tf
new file mode 100644
index 000000000..264441e55
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_sqs_amendments.tf
@@ -0,0 +1,16 @@
+# Queue to transport letter status amendment messages
+module "amendments_queue" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+
+ name = "amendments_queue"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+
+ sqs_kms_key_arn = module.kms.key_arn
+
+ create_dlq = true
+}
diff --git a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf b/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf
index a604faaf5..4d2c581b1 100644
--- a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf
+++ b/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf
@@ -1,4 +1,5 @@
-# Queue to transport update letter status messages
+# Queue to transport update letter status messages. Now replaced by module.amendments_queue.
+# This queue will not be removed just yet, to allow it to be drained following the release in which module.amendments_queue replaces it.
module "letter_status_updates_queue" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts
index 4c44ddbe6..193c1c077 100644
--- a/internal/datastore/src/__test__/letter-repository.test.ts
+++ b/internal/datastore/src/__test__/letter-repository.test.ts
@@ -14,10 +14,12 @@ function createLetter(
supplierId: string,
letterId: string,
status: Letter["status"] = "PENDING",
+ eventId?: string,
): InsertLetter {
const now = new Date().toISOString();
return {
id: letterId,
+ eventId,
supplierId,
specificationId: "specification1",
groupId: "group1",
@@ -168,6 +170,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "REJECTED",
reasonCode: "R01",
@@ -180,6 +183,7 @@ describe("LetterRepository", () => {
"letter1",
);
expect(updatedLetter.status).toBe("REJECTED");
+ expect(updatedLetter.previousStatus).toBe("PENDING");
expect(updatedLetter.reasonCode).toBe("R01");
expect(updatedLetter.reasonText).toBe("Reason text");
});
@@ -199,6 +203,7 @@ describe("LetterRepository", () => {
jest.setSystemTime(new Date(2020, 1, 2));
const letterDto: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -215,6 +220,7 @@ describe("LetterRepository", () => {
test("can't update a letter that does not exist", async () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -233,6 +239,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -241,6 +248,52 @@ describe("LetterRepository", () => {
).rejects.toThrow("Cannot do operations on a non-existent table");
});
+ test("does not update a letter if the same eventId is used", async () => {
+ const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
+ await letterRepository.putLetter(letter);
+
+ const duplicateUpdate: UpdateLetter = {
+ id: "letter1",
+ eventId: "event1",
+ supplierId: "supplier1",
+ status: "REJECTED",
+ reasonCode: "R01",
+ };
+ const result = await letterRepository.updateLetterStatus(duplicateUpdate);
+
+ expect(result).toBeUndefined();
+ const unchangedLetter = await letterRepository.getLetterById(
+ "supplier1",
+ "letter1",
+ );
+ expect(unchangedLetter.status).toBe("DELIVERED");
+ expect(unchangedLetter.eventId).toBe("event1");
+ expect(unchangedLetter.reasonCode).toBeUndefined();
+ });
+
+ test("updates a letter if a different eventId is used", async () => {
+ const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
+ await letterRepository.putLetter(letter);
+
+ const duplicateUpdate: UpdateLetter = {
+ id: "letter1",
+ eventId: "event2",
+ supplierId: "supplier1",
+ status: "REJECTED",
+ reasonCode: "R01",
+ };
+ const result = await letterRepository.updateLetterStatus(duplicateUpdate);
+
+ expect(result).toBeDefined();
+ const changedLetter = await letterRepository.getLetterById(
+ "supplier1",
+ "letter1",
+ );
+ expect(changedLetter.status).toBe("REJECTED");
+ expect(changedLetter.eventId).toBe("event2");
+ expect(changedLetter.reasonCode).toBe("R01");
+ });
+
test("should return a list of letters matching status", async () => {
await letterRepository.putLetter(createLetter("supplier1", "letter1"));
await letterRepository.putLetter(createLetter("supplier1", "letter2"));
@@ -278,6 +331,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts
index f22868789..def7c1b36 100644
--- a/internal/datastore/src/letter-repository.ts
+++ b/internal/datastore/src/letter-repository.ts
@@ -7,6 +7,7 @@ import {
UpdateCommand,
UpdateCommandOutput,
} from "@aws-sdk/lib-dynamodb";
+import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { Logger } from "pino";
import { z } from "zod";
import {
@@ -163,32 +164,16 @@ export class LetterRepository {
};
}
- async updateLetterStatus(letterToUpdate: UpdateLetter): Promise {
+ async updateLetterStatus(
+ letterToUpdate: UpdateLetter,
+ ): Promise {
this.log.debug(
`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
);
let result: UpdateCommandOutput;
try {
- let updateExpression =
- "set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl";
- const expressionAttributeValues: Record = {
- ":status": letterToUpdate.status,
- ":updatedAt": new Date().toISOString(),
- ":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
- ":ttl": Math.floor(
- Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
- ),
- };
-
- if (letterToUpdate.reasonCode) {
- updateExpression += ", reasonCode = :reasonCode";
- expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
- }
-
- if (letterToUpdate.reasonText) {
- updateExpression += ", reasonText = :reasonText";
- expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
- }
+ const { expressionAttributeValues, updateExpression } =
+ this.buildUpdateExpression(letterToUpdate);
result = await this.ddbClient.send(
new UpdateCommand({
@@ -198,31 +183,61 @@ export class LetterRepository {
supplierId: letterToUpdate.supplierId,
},
UpdateExpression: updateExpression,
- ConditionExpression: "attribute_exists(id)", // Ensure letter exists
+ ConditionExpression:
+ "attribute_exists(id) AND (attribute_not_exists(eventId) OR eventId <> :eventId)",
ExpressionAttributeNames: {
"#status": "status",
"#ttl": "ttl",
},
ExpressionAttributeValues: expressionAttributeValues,
ReturnValues: "ALL_NEW",
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}),
);
+
+ this.log.debug(
+ `Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
+ );
+ return LetterSchema.parse(result.Attributes);
} catch (error) {
- if (
- error instanceof Error &&
- error.name === "ConditionalCheckFailedException"
- ) {
+ if (error instanceof ConditionalCheckFailedException) {
+ if (error.Item?.eventId.S === letterToUpdate.eventId) {
+ this.log.warn(
+ `Skipping update for letter ${letterToUpdate.id}: eventId ${letterToUpdate.eventId} already processed`,
+ );
+ return undefined;
+ }
throw new Error(
`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`,
);
}
throw error;
}
+ }
- this.log.debug(
- `Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
- );
- return LetterSchema.parse(result.Attributes);
+ private buildUpdateExpression(letterToUpdate: UpdateLetter) {
+ let updateExpression = `set #status = :status, previousStatus = #status, updatedAt = :updatedAt, supplierStatus = :supplierStatus,
+ #ttl = :ttl, eventId = :eventId`;
+ const expressionAttributeValues: Record = {
+ ":status": letterToUpdate.status,
+ ":updatedAt": new Date().toISOString(),
+ ":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
+ ":ttl": Math.floor(
+ Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
+ ),
+ ":eventId": letterToUpdate.eventId,
+ };
+
+ if (letterToUpdate.reasonCode) {
+ updateExpression += ", reasonCode = :reasonCode";
+ expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
+ }
+
+ if (letterToUpdate.reasonText) {
+ updateExpression += ", reasonText = :reasonText";
+ expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
+ }
+ return { updateExpression, expressionAttributeValues };
}
async getLettersBySupplier(
diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts
index a0b9f719c..fd83e0403 100644
--- a/internal/datastore/src/types.ts
+++ b/internal/datastore/src/types.ts
@@ -42,9 +42,11 @@ export const LetterSchemaBase = z.object({
export const LetterSchema = LetterSchemaBase.extend({
supplierId: idRef(SupplierSchema, "id"),
+ eventId: z.string().optional(),
url: z.url(),
createdAt: z.string(),
updatedAt: z.string(),
+ previousStatus: LetterStatus.optional(),
supplierStatus: z.string().describe("Secondary index PK"),
supplierStatusSk: z.string().describe("Secondary index SK"),
ttl: z.int(),
@@ -67,6 +69,7 @@ export type InsertLetter = Omit<
>;
export type UpdateLetter = {
id: string;
+ eventId: string;
supplierId: string;
status: Letter["status"];
reasonCode?: string;
diff --git a/internal/events/jest.config.ts b/internal/events/jest.config.ts
index 84251001b..926706a37 100644
--- a/internal/events/jest.config.ts
+++ b/internal/events/jest.config.ts
@@ -24,7 +24,7 @@ export const baseJestConfig: Config = {
},
},
- coveragePathIgnorePatterns: ["/__tests__/"],
+ coveragePathIgnorePatterns: ["/src/index.ts$", "/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
diff --git a/internal/events/package.json b/internal/events/package.json
index 40cd8bfcd..4da8a4861 100644
--- a/internal/events/package.json
+++ b/internal/events/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
+ "@internal/datastore": "*",
"zod": "^4.1.11"
},
"description": "Schemas for NHS Notify Supplier API events",
@@ -50,5 +51,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
- "version": "1.0.11"
+ "version": "1.0.12"
}
diff --git a/internal/events/src/__tests__/version.test.ts b/internal/events/src/__tests__/version.test.ts
new file mode 100644
index 000000000..412eef4ce
--- /dev/null
+++ b/internal/events/src/__tests__/version.test.ts
@@ -0,0 +1,11 @@
+import { MAJOR_VERSION, VERSION } from "../version";
+
+describe("version exports", () => {
+ it("should export MAJOR_VERSION as the first segment of the version", () => {
+ expect(VERSION.startsWith(`${MAJOR_VERSION}.`)).toBeTruthy();
+ });
+
+ it("should have VERSION in semver format", () => {
+ expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
+ });
+});
diff --git a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts b/internal/events/src/events/__tests__/letter-mapper.test.ts
similarity index 94%
rename from lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts
rename to internal/events/src/events/__tests__/letter-mapper.test.ts
index 077d73792..c870dc91b 100644
--- a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts
+++ b/internal/events/src/events/__tests__/letter-mapper.test.ts
@@ -1,6 +1,6 @@
import { $LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { Letter } from "@internal/datastore";
-import mapLetterToCloudEvent from "../letter-mapper";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
describe("letter-mapper", () => {
it("maps a letter to a letter event", async () => {
diff --git a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts b/internal/events/src/events/letter-mapper.ts
similarity index 86%
rename from lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts
rename to internal/events/src/events/letter-mapper.ts
index f2f25a827..91f72988a 100644
--- a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts
+++ b/internal/events/src/events/letter-mapper.ts
@@ -1,10 +1,11 @@
-import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { randomBytes, randomUUID } from "node:crypto";
import eventSchemaPackage from "@nhsdigital/nhs-notify-event-schemas-supplier-api/package.json";
-import { LetterForEventPub } from "../types";
+import { Letter } from "@internal/datastore";
+import { LetterEvent } from "./letter-events";
-export default function mapLetterToCloudEvent(
- letter: LetterForEventPub,
+// eslint-disable-next-line import-x/prefer-default-export
+export function mapLetterToCloudEvent(
+ letter: Letter,
source: string,
): LetterEvent {
const eventId = randomUUID();
diff --git a/internal/events/src/index.ts b/internal/events/src/index.ts
index 339ddcd64..6aecfa516 100644
--- a/internal/events/src/index.ts
+++ b/internal/events/src/index.ts
@@ -4,4 +4,5 @@ export { default as DomainBase } from "./domain/domain-base";
export * from "./events/event-envelope";
export * from "./events/letter-events";
export * from "./events/mi-events";
+export * from "./events/letter-mapper";
export * from "./version";
diff --git a/internal/events/tsconfig.json b/internal/events/tsconfig.json
index 167e805ad..ad730e287 100644
--- a/internal/events/tsconfig.json
+++ b/internal/events/tsconfig.json
@@ -3,8 +3,7 @@
"declaration": true,
"isolatedModules": true,
"module": "commonjs",
- "outDir": "dist",
- "resolveJsonModule": true
+ "outDir": "dist"
},
"exclude": [
"node_modules",
diff --git a/lambdas/api-handler/package.json b/lambdas/api-handler/package.json
index 5bdaf1914..6983976e6 100644
--- a/lambdas/api-handler/package.json
+++ b/lambdas/api-handler/package.json
@@ -2,13 +2,15 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/client-s3": "^3.925.0",
+ "@aws-sdk/client-sns": "^3.925.0",
"@aws-sdk/client-sqs": "^3.925.0",
"@aws-sdk/lib-dynamodb": "^3.925.0",
"@aws-sdk/s3-request-presigner": "^3.925.0",
"@internal/datastore": "*",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.6",
+ "aws-lambda": "^1.0.7",
"esbuild": "0.27.2",
"pino": "^10.3.0",
"zod": "^4.1.11"
diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts
index afbca1d82..6b52a3474 100644
--- a/lambdas/api-handler/src/config/__tests__/env.test.ts
+++ b/lambdas/api-handler/src/config/__tests__/env.test.ts
@@ -25,6 +25,8 @@ describe("lambdaEnv", () => {
process.env.DOWNLOAD_URL_TTL_SECONDS = "60";
process.env.MAX_LIMIT = "2500";
process.env.QUEUE_URL = "url";
+ process.env.EVENT_SOURCE = "supplier-api";
+ process.env.SNS_TOPIC_ARN = "sns-topic.arn";
const { envVars } = require("../env");
@@ -38,6 +40,8 @@ describe("lambdaEnv", () => {
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500,
QUEUE_URL: "url",
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns-topic.arn",
});
});
@@ -61,6 +65,8 @@ describe("lambdaEnv", () => {
process.env.LETTER_TTL_HOURS = "12960";
process.env.MI_TTL_HOURS = "2160";
process.env.DOWNLOAD_URL_TTL_SECONDS = "60";
+ process.env.EVENT_SOURCE = "supplier-api";
+ process.env.SNS_TOPIC_ARN = "sns-topic.arn";
const { envVars } = require("../env");
@@ -73,6 +79,8 @@ describe("lambdaEnv", () => {
MI_TTL_HOURS: 2160,
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: undefined,
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns-topic.arn",
});
});
});
diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts
index 1a64998f4..ff4a1020f 100644
--- a/lambdas/api-handler/src/config/deps.ts
+++ b/lambdas/api-handler/src/config/deps.ts
@@ -2,6 +2,7 @@ import { S3Client } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { SQSClient } from "@aws-sdk/client-sqs";
+import { SNSClient } from "@aws-sdk/client-sns";
import { Logger } from "pino";
import {
DBHealthcheck,
@@ -14,6 +15,7 @@ import { EnvVars, envVars } from "./env";
export type Deps = {
s3Client: S3Client;
sqsClient: SQSClient;
+ snsClient: SNSClient;
letterRepo: LetterRepository;
miRepo: MIRepository;
dbHealthcheck: DBHealthcheck;
@@ -62,6 +64,7 @@ export function createDependenciesContainer(): Deps {
return {
s3Client: new S3Client(),
sqsClient: new SQSClient(),
+ snsClient: new SNSClient(),
letterRepo: createLetterRepository(log, envVars),
miRepo: createMIRepository(log, envVars),
dbHealthcheck: createDBHealthcheck(envVars),
diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts
index 0a98f2b4d..be22d3182 100644
--- a/lambdas/api-handler/src/config/env.ts
+++ b/lambdas/api-handler/src/config/env.ts
@@ -11,6 +11,8 @@ const EnvVarsSchema = z.object({
MAX_LIMIT: z.coerce.number().int().optional(),
QUEUE_URL: z.coerce.string().optional(),
PINO_LOG_LEVEL: z.coerce.string().optional(),
+ EVENT_SOURCE: z.string(),
+ SNS_TOPIC_ARN: z.string(),
});
export type EnvVars = z.infer;
diff --git a/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts b/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts
new file mode 100644
index 000000000..cce00574c
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts
@@ -0,0 +1,183 @@
+import { Context, SQSEvent, SQSRecord } from "aws-lambda";
+import { mockDeep } from "jest-mock-extended";
+import pino from "pino";
+import { SNSClient } from "@aws-sdk/client-sns";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import { Letter, LetterRepository } from "@internal/datastore/src";
+import { UpdateLetterCommand } from "../../contracts/letters";
+import { EnvVars } from "../../config/env";
+import { Deps } from "../../config/deps";
+import createTransformAmendmentEventHandler from "../amendment-event-transformer";
+
+// Make crypto return consistent values, since we"re calling it in both prod and test code and comparing the values
+const realCrypto = jest.requireActual("crypto");
+const randomBytes: Record = {
+ "8": realCrypto.randomBytes(8),
+ "16": realCrypto.randomBytes(16),
+};
+jest.mock("crypto", () => ({
+ randomUUID: () => "4616b2d9-b7a5-45aa-8523-fa7419626b69",
+ randomBytes: (size: number) => randomBytes[String(size)],
+}));
+
+const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => {
+ const records: Partial[] = updateLetterCommand.map((letter) => {
+ return {
+ messageId: `mid-${letter.id}`,
+ body: JSON.stringify(letter),
+ messageAttributes: {
+ CorrelationId: {
+ dataType: "String",
+ stringValue: `correlationId-${letter.id}`,
+ },
+ },
+ };
+ });
+
+ const event: Partial = {
+ Records: records as SQSRecord[],
+ };
+
+ return event as SQSEvent;
+};
+
+describe("createLetterStatusUpdateHandler", () => {
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ });
+
+ const mockedDeps: jest.Mocked = {
+ snsClient: { send: jest.fn() } as unknown as SNSClient,
+ letterRepo: {
+ getLetterById: jest.fn(),
+ } as unknown as LetterRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns_topic.arn",
+ } as unknown as EnvVars,
+ } as Deps;
+
+ const letters: Letter[] = [
+ {
+ id: "id1",
+ supplierId: "s1",
+ status: "PENDING",
+ } as Letter,
+ {
+ id: "id2",
+ supplierId: "s2",
+ status: "PENDING",
+ } as Letter,
+ {
+ id: "id3",
+ supplierId: "s3",
+ status: "PENDING",
+ } as Letter,
+ ];
+
+ const updateLetterCommands: UpdateLetterCommand[] = [
+ {
+ ...letters[0],
+ status: "REJECTED",
+ reasonCode: "123",
+ reasonText: "Reason text",
+ },
+ { ...letters[1], status: "ACCEPTED" },
+ { ...letters[2], status: "DELIVERED" },
+ ];
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("processes letters successfully", async () => {
+ (mockedDeps.letterRepo.getLetterById as jest.Mock)
+ .mockResolvedValueOnce(letters[0])
+ .mockResolvedValueOnce(letters[1])
+ .mockResolvedValueOnce(letters[2]);
+
+ const context = mockDeep();
+ const callback = jest.fn();
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ await transformAmendmentEventHandler(
+ buildEvent(updateLetterCommands),
+ context,
+ callback,
+ );
+
+ for (let i = 0; i < 3; i++) {
+ expect(mockedDeps.snsClient.send).toHaveBeenNthCalledWith(
+ i + 1,
+ expect.objectContaining({
+ input: expect.objectContaining({
+ TopicArn: mockedDeps.env.SNS_TOPIC_ARN,
+ Message: JSON.stringify(
+ mapLetterToCloudEvent(
+ updateLetterCommands[i] as Letter,
+ mockedDeps.env.EVENT_SOURCE,
+ ),
+ ),
+ }),
+ }),
+ );
+ }
+ });
+
+ it("logs error if error thrown when updating", async () => {
+ const mockError = new Error("Update error");
+ (mockedDeps.snsClient.send as jest.Mock).mockRejectedValue(mockError);
+ (mockedDeps.letterRepo.getLetterById as jest.Mock).mockResolvedValueOnce(
+ letters[1],
+ );
+
+ const context = mockDeep();
+ const callback = jest.fn();
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ await transformAmendmentEventHandler(
+ buildEvent([updateLetterCommands[1]]),
+ context,
+ callback,
+ );
+
+ expect(mockedDeps.logger.error).toHaveBeenCalledWith({
+ description: "Error processing letter status update",
+ err: mockError,
+ messageId: "mid-id2",
+ correlationId: "correlationId-id2",
+ messageBody: '{"id":"id2","supplierId":"s2","status":"ACCEPTED"}',
+ });
+ });
+
+ it("returns batch update failures in the response", async () => {
+ (mockedDeps.letterRepo.getLetterById as jest.Mock)
+ .mockResolvedValueOnce(letters[0])
+ .mockResolvedValueOnce(letters[1])
+ .mockResolvedValueOnce(letters[2]);
+ (mockedDeps.snsClient.send as jest.Mock).mockResolvedValueOnce({});
+ (mockedDeps.snsClient.send as jest.Mock).mockRejectedValueOnce(
+ new Error("Update error"),
+ );
+ (mockedDeps.snsClient.send as jest.Mock).mockResolvedValueOnce({});
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ const sqsBatchResponse = await transformAmendmentEventHandler(
+ buildEvent(updateLetterCommands),
+ mockDeep(),
+ jest.fn(),
+ );
+
+ expect(sqsBatchResponse?.batchItemFailures).toEqual([
+ { itemIdentifier: "mid-id2" },
+ ]);
+ });
+});
diff --git a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts
deleted file mode 100644
index 7ec1866b9..000000000
--- a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Context, SQSEvent, SQSRecord } from "aws-lambda";
-import { mockDeep } from "jest-mock-extended";
-import { S3Client } from "@aws-sdk/client-s3";
-import pino from "pino";
-import { LetterRepository } from "@internal/datastore/src";
-import { UpdateLetterCommand } from "../../contracts/letters";
-import { EnvVars } from "../../config/env";
-import { Deps } from "../../config/deps";
-import createLetterStatusUpdateHandler from "../letter-status-update";
-
-const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => {
- const records: Partial[] = updateLetterCommand.map((letter) => {
- return {
- messageId: `mid-${letter.id}`,
- body: JSON.stringify(letter),
- messageAttributes: {
- CorrelationId: {
- dataType: "String",
- stringValue: `correlationId-${letter.id}`,
- },
- },
- };
- });
-
- const event: Partial = {
- Records: records as SQSRecord[],
- };
-
- return event as SQSEvent;
-};
-
-describe("createLetterStatusUpdateHandler", () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it("processes letters successfully", async () => {
- const updateLetterCommands: UpdateLetterCommand[] = [
- {
- id: "id1",
- status: "REJECTED",
- supplierId: "s1",
- reasonCode: "123",
- reasonText: "Reason text",
- },
- {
- id: "id2",
- supplierId: "s2",
- status: "ACCEPTED",
- },
- {
- id: "id3",
- supplierId: "s3",
- status: "DELIVERED",
- },
- ];
-
- const mockedDeps: jest.Mocked = {
- s3Client: {} as unknown as S3Client,
- letterRepo: {
- updateLetterStatus: jest
- .fn()
- .mockResolvedValueOnce(updateLetterCommands[0])
- .mockResolvedValueOnce(updateLetterCommands[1])
- .mockResolvedValueOnce(updateLetterCommands[2]),
- } as unknown as LetterRepository,
- logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
- env: {
- SUPPLIER_ID_HEADER: "nhsd-supplier-id",
- APIM_CORRELATION_HEADER: "nhsd-correlation-id",
- LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME",
- LETTER_TTL_HOURS: 12_960,
- DOWNLOAD_URL_TTL_SECONDS: 60,
- MAX_LIMIT: 2500,
- QUEUE_URL: "SQS_URL",
- } as unknown as EnvVars,
- } as Deps;
-
- const context = mockDeep();
- const callback = jest.fn();
-
- const letterStatusUpdateHandler =
- createLetterStatusUpdateHandler(mockedDeps);
- await letterStatusUpdateHandler(
- buildEvent(updateLetterCommands),
- context,
- callback,
- );
-
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 1,
- updateLetterCommands[0],
- );
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 2,
- updateLetterCommands[1],
- );
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 3,
- updateLetterCommands[2],
- );
- });
-
- it("logs error if error thrown when updating", async () => {
- const mockError = new Error("Update error");
-
- const mockedDeps: jest.Mocked = {
- s3Client: {} as unknown as S3Client,
- letterRepo: {
- updateLetterStatus: jest.fn().mockRejectedValue(mockError),
- } as unknown as LetterRepository,
- logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
- env: {
- SUPPLIER_ID_HEADER: "nhsd-supplier-id",
- APIM_CORRELATION_HEADER: "nhsd-correlation-id",
- LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME",
- LETTER_TTL_HOURS: 12_960,
- DOWNLOAD_URL_TTL_SECONDS: 60,
- MAX_LIMIT: 2500,
- QUEUE_URL: "SQS_URL",
- } as unknown as EnvVars,
- } as Deps;
-
- const context = mockDeep();
- const callback = jest.fn();
-
- const updateLetterCommands: UpdateLetterCommand[] = [
- {
- id: "id1",
- status: "ACCEPTED",
- supplierId: "s1",
- },
- ];
-
- const letterStatusUpdateHandler =
- createLetterStatusUpdateHandler(mockedDeps);
- await letterStatusUpdateHandler(
- buildEvent(updateLetterCommands),
- context,
- callback,
- );
-
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledWith(
- updateLetterCommands[0],
- );
- expect(mockedDeps.logger.error).toHaveBeenCalledWith({
- description: "Error processing letter status update",
- err: mockError,
- messageId: "mid-id1",
- correlationId: "correlationId-id1",
- messageBody: '{"id":"id1","status":"ACCEPTED","supplierId":"s1"}',
- });
- });
-});
diff --git a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
new file mode 100644
index 000000000..2ae233332
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
@@ -0,0 +1,68 @@
+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 { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import {
+ UpdateLetterCommand,
+ UpdateLetterCommandSchema,
+} from "../contracts/letters";
+import { Deps } from "../config/deps";
+
+export default function createTransformAmendmentEventHandler(
+ deps: Deps,
+): SQSHandler {
+ return async (event: SQSEvent) => {
+ const batchItemFailures: SQSBatchItemFailure[] = [];
+
+ const tasks = event.Records.map(async (message) => {
+ try {
+ const updateLetterCommand: UpdateLetterCommand =
+ UpdateLetterCommandSchema.parse(JSON.parse(message.body));
+ const letter = await deps.letterRepo.getLetterById(
+ updateLetterCommand.supplierId,
+ updateLetterCommand.id,
+ );
+ letter.status = updateLetterCommand.status;
+ letter.reasonCode = updateLetterCommand.reasonCode;
+ letter.reasonText = updateLetterCommand.reasonText;
+
+ const letterEvent = mapLetterToCloudEvent(
+ letter,
+ deps.env.EVENT_SOURCE,
+ );
+ await deps.snsClient.send(
+ buildSnsCommand(letterEvent, deps.env.SNS_TOPIC_ARN),
+ );
+ deps.logger.info({
+ description: "Sent letter status update via topic",
+ letterId: updateLetterCommand.id,
+ messageId: message.messageId,
+ correlationId: message.messageAttributes.CorrelationId.stringValue,
+ });
+ } catch (error) {
+ deps.logger.error({
+ description: "Error processing letter status update",
+ err: error,
+ messageId: message.messageId,
+ correlationId: message.messageAttributes.CorrelationId.stringValue,
+ messageBody: message.body,
+ });
+ batchItemFailures.push({ itemIdentifier: message.messageId });
+ }
+ });
+
+ await Promise.all(tasks);
+
+ return { batchItemFailures };
+ };
+}
+
+function buildSnsCommand(
+ letterEvent: LetterEvent,
+ topicArn: string,
+): PublishCommand {
+ return new PublishCommand({
+ TopicArn: topicArn,
+ Message: JSON.stringify(letterEvent),
+ });
+}
diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts
deleted file mode 100644
index b49f8702a..000000000
--- a/lambdas/api-handler/src/handlers/letter-status-update.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { SQSEvent, SQSHandler } from "aws-lambda";
-import {
- UpdateLetterCommand,
- UpdateLetterCommandSchema,
-} from "../contracts/letters";
-import { Deps } from "../config/deps";
-import { mapToUpdateLetter } from "../mappers/letter-mapper";
-
-export default function createLetterStatusUpdateHandler(
- deps: Deps,
-): SQSHandler {
- return async (event: SQSEvent) => {
- const tasks = event.Records.map(async (message) => {
- try {
- const letterToUpdate: UpdateLetterCommand =
- UpdateLetterCommandSchema.parse(JSON.parse(message.body));
- await deps.letterRepo.updateLetterStatus(
- mapToUpdateLetter(letterToUpdate),
- );
- deps.logger.info({
- description: "Updated letter status",
- letterId: letterToUpdate.id,
- messageId: message.messageId,
- correlationId: message.messageAttributes.CorrelationId.stringValue,
- });
- } catch (error) {
- deps.logger.error({
- description: "Error processing letter status update",
- err: error,
- messageId: message.messageId,
- correlationId: message.messageAttributes.CorrelationId.stringValue,
- messageBody: message.body,
- });
- }
- });
-
- await Promise.all(tasks);
- };
-}
diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts
index 3a006e48d..573be050d 100644
--- a/lambdas/api-handler/src/index.ts
+++ b/lambdas/api-handler/src/index.ts
@@ -4,7 +4,7 @@ import createGetLetterDataHandler from "./handlers/get-letter-data";
import createGetLettersHandler from "./handlers/get-letters";
import createPatchLetterHandler from "./handlers/patch-letter";
import createPostLettersHandler from "./handlers/post-letters";
-import createLetterStatusUpdateHandler from "./handlers/letter-status-update";
+import createTransformAmendmentEventHandler from "./handlers/amendment-event-transformer";
import createPostMIHandler from "./handlers/post-mi";
import createGetStatusHandler from "./handlers/get-status";
@@ -14,7 +14,8 @@ export const getLetter = createGetLetterHandler(container);
export const getLetterData = createGetLetterDataHandler(container);
export const getLetters = createGetLettersHandler(container);
export const patchLetter = createPatchLetterHandler(container);
-export const letterStatusUpdate = createLetterStatusUpdateHandler(container);
+export const transformAmendmentEvent =
+ createTransformAmendmentEventHandler(container);
export const postLetters = createPostLettersHandler(container);
export const postMI = createPostMIHandler(container);
diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts
index c11d6d8c0..c31d61b34 100644
--- a/lambdas/api-handler/src/mappers/letter-mapper.ts
+++ b/lambdas/api-handler/src/mappers/letter-mapper.ts
@@ -1,4 +1,4 @@
-import { LetterBase, LetterStatus, UpdateLetter } from "@internal/datastore";
+import { LetterBase, LetterStatus } from "@internal/datastore";
import {
GetLetterResponse,
GetLetterResponseSchema,
@@ -68,22 +68,6 @@ export function mapToUpdateCommands(
}));
}
-// ---------------------------------------------
-// Map letter command to repository type
-// ---------------------------------------------
-
-export function mapToUpdateLetter(
- updateLetter: UpdateLetterCommand,
-): UpdateLetter {
- return {
- id: updateLetter.id,
- supplierId: updateLetter.supplierId,
- status: updateLetter.status,
- reasonCode: updateLetter.reasonCode,
- reasonText: updateLetter.reasonText,
- };
-}
-
// ---------------------------------------------
// Map internal datastore letter to response
// ---------------------------------------------
diff --git a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
index f00e62935..fd11de133 100644
--- a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
+++ b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
@@ -7,13 +7,12 @@ import {
KinesisStreamRecordPayload,
} from "aws-lambda";
import { mockDeep } from "jest-mock-extended";
-import { LetterBase } from "@internal/datastore";
+import { Letter } from "@internal/datastore";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
import createHandler from "../letter-updates-transformer";
import { Deps } from "../deps";
import { EnvVars } from "../env";
-import mapLetterToCloudEvent from "../mappers/letter-mapper";
import { LetterStatus } from "../../../api-handler/src/contracts/letters";
-import { LetterForEventPub } from "../types";
// Make crypto return consistent values, since we"re calling it in both prod and test code and comparing the values
const realCrypto = jest.requireActual("crypto");
@@ -171,7 +170,7 @@ describe("letter-updates-transformer Lambda", () => {
it("does not publish invalid letter data", async () => {
const handler = createHandler(mockedDeps);
const oldLetter = generateLetter("ACCEPTED");
- const newLetter = { id: oldLetter.id } as LetterForEventPub;
+ const newLetter = { id: oldLetter.id } as Letter;
const testData = generateKinesisEvent([
generateModifyRecord(oldLetter, newLetter),
@@ -324,7 +323,7 @@ describe("letter-updates-transformer Lambda", () => {
});
});
-function generateLetter(status: LetterStatus, id?: string): LetterForEventPub {
+function generateLetter(status: LetterStatus, id?: string): Letter {
return {
id: id || "1",
status,
@@ -337,14 +336,14 @@ function generateLetter(status: LetterStatus, id?: string): LetterForEventPub {
url: "https://example.com/letter.pdf",
source: "test-source",
subject: "test-source/subject-id",
+ supplierStatus: `supplier1#${status}`,
+ supplierStatusSk: "2025-12-10T11:12:54Z#1",
+ ttl: 1_234_567_890,
};
}
-function generateLetters(
- numLetters: number,
- status: LetterStatus,
-): LetterForEventPub[] {
- const letters: LetterForEventPub[] = Array.from({ length: numLetters });
+function generateLetters(numLetters: number, status: LetterStatus): Letter[] {
+ const letters: Letter[] = Array.from({ length: numLetters });
for (let i = 0; i < numLetters; i++) {
letters[i] = generateLetter(status, String(i + 1));
}
@@ -352,31 +351,34 @@ function generateLetters(
}
function generateModifyRecord(
- oldLetter: LetterForEventPub,
- newLetter: LetterForEventPub,
+ oldLetter: Letter,
+ newLetter: Letter,
): DynamoDBRecord {
- const oldImage = Object.fromEntries(
- Object.entries(oldLetter).map(([key, value]) => [key, { S: value }]),
- );
- const newImage = Object.fromEntries(
- Object.entries(newLetter).map(([key, value]) => [key, { S: value }]),
- );
+ const oldImage = buildStreamImage(oldLetter);
+ const newImage = buildStreamImage(newLetter);
return {
eventName: "MODIFY",
dynamodb: { OldImage: oldImage, NewImage: newImage },
};
}
-function generateInsertRecord(newLetter: LetterBase): DynamoDBRecord {
- const newImage = Object.fromEntries(
- Object.entries(newLetter).map(([key, value]) => [key, { S: value }]),
- );
+function generateInsertRecord(newLetter: Letter): DynamoDBRecord {
+ const newImage = buildStreamImage(newLetter);
return {
eventName: "INSERT",
dynamodb: { NewImage: newImage },
};
}
+function buildStreamImage(letter: Letter) {
+ return Object.fromEntries(
+ Object.entries(letter).map(([key, value]) => [
+ key,
+ typeof value === "number" ? { N: String(value) } : { S: value },
+ ]),
+ );
+}
+
function generateKinesisEvent(letterEvents: object[]): KinesisStreamEvent {
const records = letterEvents
.map((letter) => Buffer.from(JSON.stringify(letter)).toString("base64"))
diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
index 11b83c3b9..8ba2e1dd3 100644
--- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
+++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
@@ -10,10 +10,10 @@ import {
PublishBatchRequestEntry,
} from "@aws-sdk/client-sns";
import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import { Letter, LetterSchema } from "@internal/datastore";
import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics";
-import mapLetterToCloudEvent from "./mappers/letter-mapper";
import { Deps } from "./deps";
-import { LetterForEventPub, LetterSchemaForEventPub } from "./types";
// SNS PublishBatchCommand supports up to 10 messages per batch
const BATCH_SIZE = 10;
@@ -140,9 +140,9 @@ function isChanged(record: DynamoDBRecord, property: string): boolean {
return oldValue?.S !== newValue?.S;
}
-function extractNewLetter(record: DynamoDBRecord): LetterForEventPub {
+function extractNewLetter(record: DynamoDBRecord): Letter {
const newImage = record.dynamodb?.NewImage!;
- return LetterSchemaForEventPub.parse(unmarshall(newImage as any));
+ return LetterSchema.parse(unmarshall(newImage as any));
}
function* generateBatches(events: LetterEvent[]) {
diff --git a/lambdas/letter-updates-transformer/src/types.ts b/lambdas/letter-updates-transformer/src/types.ts
deleted file mode 100644
index 34920991b..000000000
--- a/lambdas/letter-updates-transformer/src/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { LetterSchema } from "@internal/datastore";
-import { z } from "zod";
-
-export const LetterSchemaForEventPub = LetterSchema.omit({
- supplierStatus: true,
- supplierStatusSk: true,
- ttl: true,
-});
-
-export type LetterForEventPub = z.infer;
diff --git a/lambdas/letter-updates-transformer/tsconfig.json b/lambdas/letter-updates-transformer/tsconfig.json
index f3fa0970e..bb8177b74 100644
--- a/lambdas/letter-updates-transformer/tsconfig.json
+++ b/lambdas/letter-updates-transformer/tsconfig.json
@@ -1,7 +1,6 @@
{
"compilerOptions": {
- "esModuleInterop": true,
- "resolveJsonModule": true
+ "esModuleInterop": true
},
"extends": "../../tsconfig.base.json",
"include": [
diff --git a/lambdas/mi-updates-transformer/tsconfig.json b/lambdas/mi-updates-transformer/tsconfig.json
index f3fa0970e..bb8177b74 100644
--- a/lambdas/mi-updates-transformer/tsconfig.json
+++ b/lambdas/mi-updates-transformer/tsconfig.json
@@ -1,7 +1,6 @@
{
"compilerOptions": {
- "esModuleInterop": true,
- "resolveJsonModule": true
+ "esModuleInterop": true
},
"extends": "../../tsconfig.base.json",
"include": [
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 73826cd53..a94942d06 100644
--- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
+++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
@@ -237,6 +237,7 @@ describe("createUpsertLetterHandler", () => {
const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock
.calls[0][0];
expect(firstArg.id).toBe("letter1");
+ expect(firstArg.eventId).toBe("7b9a03ca-342a-4150-b56b-989109c45613");
expect(firstArg.supplierId).toBe("supplier1");
expect(firstArg.specificationId).toBe("spec1");
expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf");
diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts
index 49232369b..67893ff22 100644
--- a/lambdas/upsert-letter/src/handler/upsert-handler.ts
+++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts
@@ -89,6 +89,7 @@ function mapToInsertLetter(
const now = new Date().toISOString();
return {
id: upsertRequest.data.domainId,
+ eventId: upsertRequest.id,
supplierId: supplier,
status: "PENDING",
specificationId: spec,
@@ -108,6 +109,7 @@ function mapToInsertLetter(
function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter {
return {
id: upsertRequest.data.domainId,
+ eventId: upsertRequest.id,
supplierId: upsertRequest.data.supplierId,
status: upsertRequest.data.status,
reasonCode: upsertRequest.data.reasonCode,
diff --git a/package-lock.json b/package-lock.json
index a63b758e2..d33bae5f3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -148,6 +148,7 @@
"license": "MIT",
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
+ "@internal/datastore": "*",
"zod": "^4.1.11"
},
"devDependencies": {
@@ -251,13 +252,15 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/client-s3": "^3.925.0",
+ "@aws-sdk/client-sns": "^3.925.0",
"@aws-sdk/client-sqs": "^3.925.0",
"@aws-sdk/lib-dynamodb": "^3.925.0",
"@aws-sdk/s3-request-presigner": "^3.925.0",
"@internal/datastore": "*",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.6",
+ "aws-lambda": "^1.0.7",
"esbuild": "0.27.2",
"pino": "^10.3.0",
"zod": "^4.1.11"
@@ -275,6 +278,8 @@
},
"lambdas/api-handler/node_modules/argparse": {
"version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
@@ -282,6 +287,8 @@
},
"lambdas/api-handler/node_modules/aws-lambda": {
"version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/aws-lambda/-/aws-lambda-1.0.7.tgz",
+ "integrity": "sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==",
"license": "MIT",
"dependencies": {
"aws-sdk": "^2.814.0",
@@ -295,10 +302,14 @@
},
"lambdas/api-handler/node_modules/commander": {
"version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz",
+ "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==",
"license": "MIT"
},
"lambdas/api-handler/node_modules/js-yaml": {
"version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
diff --git a/package.json b/package.json
index ca391b99d..ee994074a 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"overrides": {
"axios": "^1.13.5",
"fast-xml-parser": "^5.3.4",
+ "@isaacs/brace-expansion": "^5.0.1",
"pretty-format": {
"react-is": "19.0.0"
},
diff --git a/tsconfig.base.json b/tsconfig.base.json
index b340a8fa2..a0f424750 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -7,6 +7,7 @@
"module": "ES2020",
"moduleResolution": "node",
"noEmit": true,
+ "resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"