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"