From d74a0bda77b2a1d9da2b1b972b9076c0414f9f7b Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Thu, 22 Jan 2026 15:45:06 +0000 Subject: [PATCH 01/12] Switch letter_status_update lambda to use eventsub SNS topic --- ...ent_source_mapping_letter_status_update.tf | 9 + .../terraform/components/api/locals.tf | 2 + .../api/module_lambda_letter_status_update.tf | 16 +- internal/events/jest.config.ts | 2 +- internal/events/package.json | 3 +- .../events}/__tests__/letter-mapper.test.ts | 2 +- .../events/src/events}/letter-mapper.ts | 9 +- internal/events/src/index.ts | 1 + internal/events/tsconfig.json | 3 +- lambdas/api-handler/package.json | 2 + .../src/config/__tests__/env.test.ts | 8 + lambdas/api-handler/src/config/deps.ts | 3 + lambdas/api-handler/src/config/env.ts | 2 + .../__tests__/letter-status-update.test.ts | 203 ++++++++++-------- .../src/handlers/letter-status-update.ts | 39 +++- .../api-handler/src/mappers/letter-mapper.ts | 18 +- .../letter-updates-transformer.test.ts | 46 ++-- .../src/letter-updates-transformer.ts | 8 +- .../letter-updates-transformer/src/types.ts | 10 - .../letter-updates-transformer/tsconfig.json | 3 +- lambdas/mi-updates-transformer/tsconfig.json | 3 +- tsconfig.base.json | 1 + 22 files changed, 233 insertions(+), 160 deletions(-) create mode 100644 infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf rename {lambdas/letter-updates-transformer/src/mappers => internal/events/src/events}/__tests__/letter-mapper.test.ts (97%) rename {lambdas/letter-updates-transformer/src/mappers => internal/events/src/events}/letter-mapper.ts (86%) delete mode 100644 lambdas/letter-updates-transformer/src/types.ts 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..82e1c54df --- /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.letter_status_update.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 683156a05..334ac5ac2 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_letter_status_update.tf index 59393bd29..d3ff8715a 100644 --- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf +++ b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf @@ -59,7 +59,6 @@ data "aws_iam_policy_document" "letter_status_update" { actions = [ "dynamodb:GetItem", "dynamodb:Query", - "dynamodb:UpdateItem", ] resources = [ @@ -79,7 +78,20 @@ 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 + ] + } + + statement { + sid = "AllowSNSPublish" + effect = "Allow" + + actions = [ + "sns:Publish" + ] + + resources = [ + module.eventsub.sns_topic.arn ] } } 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 08abc7453..f8d819273 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.9" + "version": "1.0.10" } 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 97% 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..0166ac96d 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 "../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 c4255d26c..8313c9b0f 100644 --- a/internal/events/src/index.ts +++ b/internal/events/src/index.ts @@ -4,3 +4,4 @@ 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"; 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 1306f33d3..5ebfed49f 100644 --- a/lambdas/api-handler/package.json +++ b/lambdas/api-handler/package.json @@ -2,11 +2,13 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.925.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-lambda": "^1.0.7", "esbuild": "^0.25.11", "pino": "^9.7.0", 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 c8d187bf4..0c5a6fa27 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 pino from "pino"; import { DBHealthcheck, @@ -13,6 +14,7 @@ import { EnvVars, envVars } from "./env"; export type Deps = { s3Client: S3Client; sqsClient: SQSClient; + snsClient: SNSClient; letterRepo: LetterRepository; miRepo: MIRepository; dbHealthcheck: DBHealthcheck; @@ -64,6 +66,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 fbdc0924d..bb1ba609a 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -10,6 +10,8 @@ const EnvVarsSchema = z.object({ DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int(), MAX_LIMIT: z.coerce.number().int().optional(), QUEUE_URL: 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__/letter-status-update.test.ts b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts index 908ba1a3d..bd267f1d8 100644 --- a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts @@ -1,13 +1,25 @@ 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 { 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 createLetterStatusUpdateHandler from "../letter-status-update"; +// 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 { @@ -30,51 +42,64 @@ const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => { }; describe("createLetterStatusUpdateHandler", () => { - beforeEach(() => { + 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 () => { - 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; + (mockedDeps.letterRepo.getLetterById as jest.Mock) + .mockResolvedValueOnce(letters[0]) + .mockResolvedValueOnce(letters[1]) + .mockResolvedValueOnce(letters[2]); const context = mockDeep(); const callback = jest.fn(); @@ -87,70 +112,74 @@ describe("createLetterStatusUpdateHandler", () => { 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], - ); + 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"); - - 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; + (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 updateLetterCommands: UpdateLetterCommand[] = [ - { - id: "id1", - status: "ACCEPTED", - supplierId: "s1", - }, - ]; - const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); await letterStatusUpdateHandler( - buildEvent(updateLetterCommands), + buildEvent([updateLetterCommands[1]]), context, callback, ); - expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledWith( - updateLetterCommands[0], - ); expect(mockedDeps.logger.error).toHaveBeenCalledWith( { err: mockError, - messageId: "mid-id1", - correlationId: "correlationId-id1", - messageBody: '{"id":"id1","status":"ACCEPTED","supplierId":"s1"}', + messageId: "mid-id2", + correlationId: "correlationId-id2", + messageBody: '{"id":"id2","supplierId":"s2","status":"ACCEPTED"}', }, "Error processing letter status update", ); }); + + 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 letterStatusUpdateHandler = + createLetterStatusUpdateHandler(mockedDeps); + const sqsBatchResponse = await letterStatusUpdateHandler( + buildEvent(updateLetterCommands), + mockDeep(), + jest.fn(), + ); + + expect(sqsBatchResponse?.batchItemFailures).toEqual([ + { itemIdentifier: "mid-id2" }, + ]); + }); }); diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index fa14e672d..8f2d6f58a 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -1,21 +1,37 @@ -import { SQSEvent, SQSHandler } from "aws-lambda"; +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"; -import { mapToUpdateLetter } from "../mappers/letter-mapper"; export default function createLetterStatusUpdateHandler( deps: Deps, ): SQSHandler { return async (event: SQSEvent) => { + const batchItemFailures: SQSBatchItemFailure[] = []; + const tasks = event.Records.map(async (message) => { try { - const letterToUpdate: UpdateLetterCommand = + const updateLetterCommand: UpdateLetterCommand = UpdateLetterCommandSchema.parse(JSON.parse(message.body)); - await deps.letterRepo.updateLetterStatus( - mapToUpdateLetter(letterToUpdate), + 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), ); } catch (error) { deps.logger.error( @@ -27,9 +43,22 @@ export default function createLetterStatusUpdateHandler( }, "Error processing letter status update", ); + 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/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 065a9a1e3..583ea0143 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 c53151c07..c0de285d9 100644 --- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts +++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts @@ -10,9 +10,9 @@ import { PublishBatchRequestEntry, } from "@aws-sdk/client-sns"; import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src"; -import mapLetterToCloudEvent from "./mappers/letter-mapper"; +import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper"; +import { Letter, LetterSchema } from "@internal/datastore"; import { Deps } from "./deps"; -import { LetterForEventPub, LetterSchemaForEventPub } from "./types"; // SNS PublishBatchCommand supports up to 10 messages per batch const BATCH_SIZE = 10; @@ -109,9 +109,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/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" From aabf50d4916eaa2108bc7373dcd7f6886e29029e Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 23 Jan 2026 15:53:07 +0000 Subject: [PATCH 02/12] Introduce idempotency: replayed update with same ID will be ignored --- .../src/__test__/letter-repository.test.ts | 53 +++++++++++++ internal/datastore/src/letter-repository.ts | 75 +++++++++++-------- internal/datastore/src/types.ts | 2 + .../handler/__tests__/upsert-handler.test.ts | 1 + .../src/handler/upsert-handler.ts | 2 + 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 4c44ddbe6..a634b7872 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", @@ -199,6 +202,7 @@ describe("LetterRepository", () => { jest.setSystemTime(new Date(2020, 1, 2)); const letterDto: UpdateLetter = { id: "letter1", + eventId: "event1", supplierId: "supplier1", status: "DELIVERED", }; @@ -215,6 +219,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 +238,7 @@ describe("LetterRepository", () => { const updateLetter: UpdateLetter = { id: "letter1", + eventId: "event1", supplierId: "supplier1", status: "DELIVERED", }; @@ -241,6 +247,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 +330,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..f9947973f 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, 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..7e4fa7e84 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -42,6 +42,7 @@ 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(), @@ -67,6 +68,7 @@ export type InsertLetter = Omit< >; export type UpdateLetter = { id: string; + eventId: string; supplierId: string; status: Letter["status"]; reasonCode?: string; 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 3f4341340..5415b2f09 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 a1b2ea08b..850ce9f31 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -74,6 +74,7 @@ function mapToInsertLetter( const now = new Date().toISOString(); return { id: upsertRequest.data.domainId, + eventId: upsertRequest.id, supplierId: supplier, status: "PENDING", specificationId: spec, @@ -93,6 +94,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, From 966f6ebc781c54b2890fefced922aff514cdcf88 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Mon, 26 Jan 2026 14:31:34 +0000 Subject: [PATCH 03/12] Store previous status --- internal/datastore/src/__test__/letter-repository.test.ts | 1 + internal/datastore/src/letter-repository.ts | 4 ++-- internal/datastore/src/types.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index a634b7872..193c1c077 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -183,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"); }); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index f9947973f..def7c1b36 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -216,8 +216,8 @@ export class LetterRepository { } private buildUpdateExpression(letterToUpdate: UpdateLetter) { - let updateExpression = - "set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl, eventId = :eventId"; + 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(), diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 7e4fa7e84..fd83e0403 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -46,6 +46,7 @@ export const LetterSchema = LetterSchemaBase.extend({ 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(), From 69e39076c6edc99cc099bc951f63c7cedcf35286 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 4 Feb 2026 15:19:52 +0000 Subject: [PATCH 04/12] Add missing test following merge with main --- internal/events/src/__tests__/version.test.ts | 11 +++++++++++ .../events/src/events/__tests__/letter-mapper.test.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 internal/events/src/__tests__/version.test.ts 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/internal/events/src/events/__tests__/letter-mapper.test.ts b/internal/events/src/events/__tests__/letter-mapper.test.ts index 0166ac96d..c870dc91b 100644 --- a/internal/events/src/events/__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 () => { From 7dd6ceacdeef1c518b265252a778d17cb0433a6c Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 6 Feb 2026 10:38:26 +0000 Subject: [PATCH 05/12] Bump test version --- internal/events/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/events/package.json b/internal/events/package.json index f8d819273..51fee7656 100644 --- a/internal/events/package.json +++ b/internal/events/package.json @@ -51,5 +51,5 @@ "typecheck": "tsc --noEmit" }, "types": "dist/index.d.ts", - "version": "1.0.10" + "version": "1.0.11" } From a93033c645271f4a3a0a01ade90c340984d6ef2d Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 6 Feb 2026 10:46:38 +0000 Subject: [PATCH 06/12] Re-enable event auditing --- infrastructure/terraform/components/api/README.md | 2 +- infrastructure/terraform/components/api/variables.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 01fe4c4be..d91c28279 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -18,7 +18,7 @@ No requirements. | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [disable\_gateway\_execute\_endpoint](#input\_disable\_gateway\_execute\_endpoint) | Disable the execution endpoint for the API Gateway | `bool` | `true` | no | | [enable\_api\_data\_trace](#input\_enable\_api\_data\_trace) | Enable API Gateway data trace logging | `bool` | `false` | no | -| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `false` | no | +| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `true` | no | | [enable\_sns\_delivery\_logging](#input\_enable\_sns\_delivery\_logging) | Enable SNS Delivery Failure Notifications | `bool` | `false` | no | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | | [eventpub\_control\_plane\_bus\_arn](#input\_eventpub\_control\_plane\_bus\_arn) | ARN of the EventBridge control plane bus for eventpub | `string` | `""` | no | diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 47928a960..2439d69f2 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -167,7 +167,7 @@ variable "core_environment" { variable "enable_event_cache" { type = bool description = "Enable caching of events to an S3 bucket" - default = false + default = true } variable "enable_sns_delivery_logging" { From 92b45c77d3b17c5370db9f539b485914651bb69f Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 5 Feb 2026 08:40:17 +0000 Subject: [PATCH 07/12] CVE-2026-25547 --- package-lock.json | 6 +++--- package.json | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b52939b16..2150861f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4212,9 +4212,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 833dc5d46..5277328d6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "name": "nhs-notify-supplier-api", "overrides": { "fast-xml-parser": "^5.3.4", + "@isaacs/brace-expansion": "^5.0.1", "pretty-format": { "react-is": "19.0.0" }, From 1671cab04ae7ff56fa01e1fcf9ff2ec34bf33010 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 4 Feb 2026 09:07:52 +0000 Subject: [PATCH 08/12] Rename "main" SNS topic to "eventsub_topic" --- .../api/event_source_mapping_mi_updates.tf | 12 ++++++------ infrastructure/terraform/components/api/locals.tf | 4 ++-- .../api/module_lambda_letter_status_update.tf | 4 ++-- .../api/module_lambda_letter_updates_transformer.tf | 2 +- .../components/api/module_sqs_letter_updates.tf | 4 ++-- .../terraform/components/api/modules_eventsub.tf | 2 +- ...topic_subscription_eventsub_sqs_letter_updates.tf | 2 +- infrastructure/terraform/modules/eventsub/README.md | 2 +- .../cloudwatch_metric_alarm_sns_delivery_failures.tf | 2 +- .../modules/eventsub/iam_role_firehose_role.tf | 4 ++-- .../terraform/modules/eventsub/iam_role_sns.tf | 4 ++-- .../eventsub/iam_role_sns_delivery_logging.tf | 4 ++-- .../modules/eventsub/module_s3bucket_event_cache.tf | 2 +- infrastructure/terraform/modules/eventsub/moved.tf | 11 +++++++++++ infrastructure/terraform/modules/eventsub/outputs.tf | 6 +++--- .../eventsub/{sns_topic.tf => sns_topic_eventsub.tf} | 2 +- ..._topic_policy.tf => sns_topic_policy_eventsub.tf} | 12 ++++++------ .../eventsub/sns_topic_subscription_firehose.tf | 2 +- 18 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 infrastructure/terraform/modules/eventsub/moved.tf rename infrastructure/terraform/modules/eventsub/{sns_topic.tf => sns_topic_eventsub.tf} (97%) rename infrastructure/terraform/modules/eventsub/{sns_topic_policy.tf => sns_topic_policy_eventsub.tf} (78%) diff --git a/infrastructure/terraform/components/api/event_source_mapping_mi_updates.tf b/infrastructure/terraform/components/api/event_source_mapping_mi_updates.tf index 73e7d394f..56a4c7b33 100644 --- a/infrastructure/terraform/components/api/event_source_mapping_mi_updates.tf +++ b/infrastructure/terraform/components/api/event_source_mapping_mi_updates.tf @@ -1,11 +1,11 @@ resource "aws_lambda_event_source_mapping" "mi_updates_transformer_kinesis" { - event_source_arn = aws_kinesis_stream.mi_change_stream.arn - function_name = module.mi_updates_transformer.function_arn - starting_position = "LATEST" - batch_size = 10 - maximum_batching_window_in_seconds = 1 + event_source_arn = aws_kinesis_stream.mi_change_stream.arn + function_name = module.mi_updates_transformer.function_arn + starting_position = "LATEST" + batch_size = 10 + maximum_batching_window_in_seconds = 1 depends_on = [ - module.mi_updates_transformer # ensures updates transformer exists + module.mi_updates_transformer # ensures updates transformer exists ] } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 334ac5ac2..027a108ab 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -27,8 +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" + SNS_TOPIC_ARN = "${module.eventsub.eventsub_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_letter_status_update.tf index d3ff8715a..4d8a94d62 100644 --- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf +++ b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf @@ -78,7 +78,7 @@ 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 ] } @@ -91,7 +91,7 @@ data "aws_iam_policy_document" "letter_status_update" { ] resources = [ - module.eventsub.sns_topic.arn + module.eventsub.eventsub_topic.arn ] } } diff --git a/infrastructure/terraform/components/api/module_lambda_letter_updates_transformer.tf b/infrastructure/terraform/components/api/module_lambda_letter_updates_transformer.tf index 52a444ec4..0f263b281 100644 --- a/infrastructure/terraform/components/api/module_lambda_letter_updates_transformer.tf +++ b/infrastructure/terraform/components/api/module_lambda_letter_updates_transformer.tf @@ -36,7 +36,7 @@ module "letter_updates_transformer" { lambda_env_vars = merge(local.common_lambda_env_vars, { EVENTPUB_SNS_TOPIC_ARN = "${module.eventpub.sns_topic.arn}", - EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters" + EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters" }) } diff --git a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf index 472afb81f..3dbcb985f 100644 --- a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf +++ b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf @@ -38,7 +38,7 @@ data "aws_iam_policy_document" "letter_updates_queue_policy" { condition { test = "ArnEquals" variable = "aws:SourceArn" - values = [module.eventsub.sns_topic.arn] + values = [module.eventsub.eventsub_topic.arn] } } @@ -65,7 +65,7 @@ data "aws_iam_policy_document" "letter_updates_queue_policy" { condition { test = "ArnEquals" variable = "aws:SourceArn" - values = [module.eventsub.sns_topic.arn] + values = [module.eventsub.eventsub_topic.arn] } } } diff --git a/infrastructure/terraform/components/api/modules_eventsub.tf b/infrastructure/terraform/components/api/modules_eventsub.tf index c55be96c0..661ff9999 100644 --- a/infrastructure/terraform/components/api/modules_eventsub.tf +++ b/infrastructure/terraform/components/api/modules_eventsub.tf @@ -22,7 +22,7 @@ module "eventsub" { sns_success_logging_sample_percent = var.sns_success_logging_sample_percent event_cache_expiry_days = 30 - enable_event_cache = var.enable_event_cache + enable_event_cache = var.enable_event_cache shared_infra_account_id = var.shared_infra_account_id } diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf index 9c232c149..317bf9729 100644 --- a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf +++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf @@ -1,5 +1,5 @@ resource "aws_sns_topic_subscription" "eventsub_sqs_letter_updates" { - topic_arn = module.eventsub.sns_topic.arn + topic_arn = module.eventsub.eventsub_topic.arn protocol = "sqs" endpoint = module.sqs_letter_updates.sqs_queue_arn } diff --git a/infrastructure/terraform/modules/eventsub/README.md b/infrastructure/terraform/modules/eventsub/README.md index 859c5fd5b..58da98278 100644 --- a/infrastructure/terraform/modules/eventsub/README.md +++ b/infrastructure/terraform/modules/eventsub/README.md @@ -39,8 +39,8 @@ | Name | Description | |------|-------------| +| [eventsub\_topic](#output\_eventsub\_topic) | SNS Topic ARN and Name | | [s3\_bucket\_event\_cache](#output\_s3\_bucket\_event\_cache) | S3 Bucket ARN and Name for event cache | -| [sns\_topic](#output\_sns\_topic) | SNS Topic ARN and Name | diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf index e8ef1249d..ceadbfaab 100644 --- a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf +++ b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf @@ -11,6 +11,6 @@ resource "aws_cloudwatch_metric_alarm" "sns_delivery_failures" { treat_missing_data = "notBreaching" dimensions = { - TopicName = aws_sns_topic.main.name + TopicName = aws_sns_topic.eventsub_topic.name } } diff --git a/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf b/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf index 69bf7618c..20414cc9a 100644 --- a/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf +++ b/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf @@ -1,8 +1,8 @@ resource "aws_iam_role" "firehose_role" { count = var.enable_event_cache ? 1 : 0 - name = "${local.csi}-firehose-role" - assume_role_policy = data.aws_iam_policy_document.firehose_assume_role[0].json + name = "${local.csi}-firehose-role" + assume_role_policy = data.aws_iam_policy_document.firehose_assume_role[0].json } data "aws_iam_policy_document" "firehose_assume_role" { diff --git a/infrastructure/terraform/modules/eventsub/iam_role_sns.tf b/infrastructure/terraform/modules/eventsub/iam_role_sns.tf index d88bea7a1..97bdc99af 100644 --- a/infrastructure/terraform/modules/eventsub/iam_role_sns.tf +++ b/infrastructure/terraform/modules/eventsub/iam_role_sns.tf @@ -1,6 +1,6 @@ resource "aws_iam_role" "sns_role" { - name = "${local.csi}-sns-role" - assume_role_policy = data.aws_iam_policy_document.sns_assume_role.json + name = "${local.csi}-sns-role" + assume_role_policy = data.aws_iam_policy_document.sns_assume_role.json } resource "aws_iam_policy" "firehose_delivery" { diff --git a/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf b/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf index 3bd25e06f..a952bfeee 100644 --- a/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf +++ b/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf @@ -1,8 +1,8 @@ resource "aws_iam_role" "sns_delivery_logging_role" { count = var.enable_sns_delivery_logging ? 1 : 0 - name = "${local.csi}-sns-delivery-logging" - assume_role_policy = data.aws_iam_policy_document.sns_delivery_logging_assume_role[0].json + name = "${local.csi}-sns-delivery-logging" + assume_role_policy = data.aws_iam_policy_document.sns_delivery_logging_assume_role[0].json } data "aws_iam_policy_document" "sns_delivery_logging_assume_role" { diff --git a/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf index f042c2d22..d51f34267 100644 --- a/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf +++ b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf @@ -48,7 +48,7 @@ module "s3bucket_event_cache" { } default_tags = { - Name = "Event Cache Storage" + Name = "Event Cache Storage" NHSE-Enable-S3-Backup-Acct = "True" } } diff --git a/infrastructure/terraform/modules/eventsub/moved.tf b/infrastructure/terraform/modules/eventsub/moved.tf new file mode 100644 index 000000000..433685ffa --- /dev/null +++ b/infrastructure/terraform/modules/eventsub/moved.tf @@ -0,0 +1,11 @@ +# Moved blocks to handle resource renames without destroy/recreate + +moved { + from = aws_sns_topic.main + to = aws_sns_topic.eventsub_topic +} + +moved { + from = aws_sns_topic_policy.main + to = aws_sns_topic_policy.eventsub_topic +} diff --git a/infrastructure/terraform/modules/eventsub/outputs.tf b/infrastructure/terraform/modules/eventsub/outputs.tf index e2ff3b38e..9ea59fe76 100644 --- a/infrastructure/terraform/modules/eventsub/outputs.tf +++ b/infrastructure/terraform/modules/eventsub/outputs.tf @@ -1,8 +1,8 @@ -output "sns_topic" { +output "eventsub_topic" { description = "SNS Topic ARN and Name" value = { - arn = aws_sns_topic.main.arn - name = aws_sns_topic.main.name + arn = aws_sns_topic.eventsub_topic.arn + name = aws_sns_topic.eventsub_topic.name } } diff --git a/infrastructure/terraform/modules/eventsub/sns_topic.tf b/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf similarity index 97% rename from infrastructure/terraform/modules/eventsub/sns_topic.tf rename to infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf index cc30db153..92c8ea124 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf @@ -1,4 +1,4 @@ -resource "aws_sns_topic" "main" { +resource "aws_sns_topic" "eventsub_topic" { name = local.csi kms_master_key_id = var.kms_key_arn diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf b/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf similarity index 78% rename from infrastructure/terraform/modules/eventsub/sns_topic_policy.tf rename to infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf index a772e9e72..38fd1e211 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf @@ -1,5 +1,5 @@ -resource "aws_sns_topic_policy" "main" { - arn = aws_sns_topic.main.arn +resource "aws_sns_topic_policy" "eventsub_topic" { + arn = aws_sns_topic.eventsub_topic.arn policy = data.aws_iam_policy_document.sns_topic_policy.json } @@ -8,7 +8,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { policy_id = "__default_policy_ID" statement { - sid = "AllowAllSNSActionsFromAccount" + sid = "AllowAllSNSActionsFromAccount" effect = "Allow" principals { @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { ] resources = [ - aws_sns_topic.main.arn, + aws_sns_topic.eventsub_topic.arn, ] condition { @@ -43,7 +43,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { } statement { - sid = "AllowAllSNSActionsFromSharedAccount" + sid = "AllowAllSNSActionsFromSharedAccount" effect = "Allow" actions = [ "SNS:Publish", @@ -57,7 +57,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { } resources = [ - aws_sns_topic.main.arn, + aws_sns_topic.eventsub_topic.arn, ] } } diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf index 42457f6de..16ad429ae 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf @@ -1,7 +1,7 @@ resource "aws_sns_topic_subscription" "firehose" { count = var.enable_event_cache ? 1 : 0 - topic_arn = aws_sns_topic.main.arn + topic_arn = aws_sns_topic.eventsub_topic.arn protocol = "firehose" subscription_role_arn = aws_iam_role.sns_role.arn endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn From a7b23d29fad5d19beb4fe9208878d655b2d213af Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 4 Feb 2026 16:00:04 +0000 Subject: [PATCH 09/12] Add new amendments SNS topic --- .../terraform/modules/eventsub/README.md | 1 + ...atch_metric_alarm_sns_delivery_failures.tf | 17 +++++ .../terraform/modules/eventsub/outputs.tf | 8 +++ .../modules/eventsub/sns_topic_eventsub.tf | 25 ++++++++ .../eventsub/sns_topic_policy_eventsub.tf | 64 +++++++++++++++++++ .../sns_topic_subscription_firehose.tf | 12 +++- 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/modules/eventsub/README.md b/infrastructure/terraform/modules/eventsub/README.md index 58da98278..55c9e876a 100644 --- a/infrastructure/terraform/modules/eventsub/README.md +++ b/infrastructure/terraform/modules/eventsub/README.md @@ -39,6 +39,7 @@ | Name | Description | |------|-------------| +| [amendments\_topic](#output\_amendments\_topic) | Amendments SNS Topic ARN and Name | | [eventsub\_topic](#output\_eventsub\_topic) | SNS Topic ARN and Name | | [s3\_bucket\_event\_cache](#output\_s3\_bucket\_event\_cache) | S3 Bucket ARN and Name for event cache | diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf index ceadbfaab..f04812dd7 100644 --- a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf +++ b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf @@ -14,3 +14,20 @@ resource "aws_cloudwatch_metric_alarm" "sns_delivery_failures" { TopicName = aws_sns_topic.eventsub_topic.name } } + +resource "aws_cloudwatch_metric_alarm" "amendments_delivery_failures" { + alarm_name = "${local.csi}-amendments-sns-delivery-failures" + alarm_description = "RELIABILITY: Alarm for amendments SNS topic delivery failures" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "NumberOfNotificationsFailed" + namespace = "AWS/SNS" + period = 300 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + + dimensions = { + TopicName = aws_sns_topic.amendments_topic.name + } +} diff --git a/infrastructure/terraform/modules/eventsub/outputs.tf b/infrastructure/terraform/modules/eventsub/outputs.tf index 9ea59fe76..891049a37 100644 --- a/infrastructure/terraform/modules/eventsub/outputs.tf +++ b/infrastructure/terraform/modules/eventsub/outputs.tf @@ -6,6 +6,14 @@ output "eventsub_topic" { } } +output "amendments_topic" { + description = "Amendments SNS Topic ARN and Name" + value = { + arn = aws_sns_topic.amendments_topic.arn + name = aws_sns_topic.amendments_topic.name + } +} + output "s3_bucket_event_cache" { description = "S3 Bucket ARN and Name for event cache" value = var.enable_event_cache ? { diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf b/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf index 92c8ea124..d4631657e 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_eventsub.tf @@ -22,3 +22,28 @@ resource "aws_sns_topic" "eventsub_topic" { sqs_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null sqs_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null } + +resource "aws_sns_topic" "amendments_topic" { + name = "${local.csi}-amendments" + kms_master_key_id = var.kms_key_arn + + application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + application_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + application_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + firehose_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + firehose_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + firehose_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + http_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + http_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + http_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + lambda_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + lambda_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + lambda_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + sqs_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + sqs_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + sqs_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null +} diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf b/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf index 38fd1e211..d2fb2fce1 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_policy_eventsub.tf @@ -4,6 +4,12 @@ resource "aws_sns_topic_policy" "eventsub_topic" { policy = data.aws_iam_policy_document.sns_topic_policy.json } +resource "aws_sns_topic_policy" "amendments_topic" { + arn = aws_sns_topic.amendments_topic.arn + + policy = data.aws_iam_policy_document.amendments_topic_policy.json +} + data "aws_iam_policy_document" "sns_topic_policy" { policy_id = "__default_policy_ID" @@ -61,3 +67,61 @@ data "aws_iam_policy_document" "sns_topic_policy" { ] } } + +data "aws_iam_policy_document" "amendments_topic_policy" { + policy_id = "__default_policy_ID" + + statement { + sid = "AllowAllSNSActionsFromAccount" + effect = "Allow" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = [ + "SNS:Subscribe", + "SNS:SetTopicAttributes", + "SNS:RemovePermission", + "SNS:Receive", + "SNS:Publish", + "SNS:ListSubscriptionsByTopic", + "SNS:GetTopicAttributes", + "SNS:DeleteTopic", + "SNS:AddPermission", + ] + + resources = [ + aws_sns_topic.eventsub_topic.arn, + ] + + condition { + test = "StringEquals" + variable = "AWS:SourceOwner" + + values = [ + var.aws_account_id, + ] + } + } + + statement { + sid = "AllowAllSNSActionsFromSharedAccount" + effect = "Allow" + actions = [ + "SNS:Publish", + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.shared_infra_account_id}:root" + ] + } + + resources = [ + aws_sns_topic.amendments_topic.arn, + ] + } +} diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf index 16ad429ae..32ad06f01 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf @@ -1,4 +1,4 @@ -resource "aws_sns_topic_subscription" "firehose" { +resource "aws_sns_topic_subscription" "firehose_eventsub" { count = var.enable_event_cache ? 1 : 0 topic_arn = aws_sns_topic.eventsub_topic.arn @@ -7,3 +7,13 @@ resource "aws_sns_topic_subscription" "firehose" { endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn raw_message_delivery = var.enable_firehose_raw_message_delivery } + +resource "aws_sns_topic_subscription" "firehose_amendments" { + count = var.enable_event_cache ? 1 : 0 + + topic_arn = aws_sns_topic.amendments_topic.arn + protocol = "firehose" + subscription_role_arn = aws_iam_role.sns_role.arn + endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn + raw_message_delivery = var.enable_firehose_raw_message_delivery +} From de6cf4179416b79fb652ef691bfd4d73cd8ab036 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Thu, 5 Feb 2026 09:04:48 +0000 Subject: [PATCH 10/12] Migrate supplier updates so that they pass through new topic --- .../terraform/components/api/README.md | 2 +- .../terraform/components/api/locals.tf | 2 +- .../api/module_lambda_letter_status_update.tf | 2 +- .../api/module_sqs_letter_updates.tf | 25 +------------------ ...ubscription_eventsub_sqs_letter_updates.tf | 6 +++++ .../terraform/components/api/variables.tf | 2 +- ..._log_group_sns_delivery_logging_failure.tf | 10 ++++++++ ..._log_group_sns_delivery_logging_success.tf | 10 ++++++++ ..._policy_sns_delivery_logging_cloudwatch.tf | 4 +++ .../src/config/__tests__/env.test.ts | 8 +++--- lambdas/api-handler/src/config/env.ts | 2 +- .../__tests__/letter-status-update.test.ts | 2 +- .../src/handlers/letter-status-update.ts | 2 +- 13 files changed, 42 insertions(+), 35 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index d91c28279..e2e1ae060 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,7 @@ No requirements. | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | | [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | -| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no | +| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `100` | no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 027a108ab..dfefd9dda 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -27,7 +27,7 @@ locals { SUPPLIER_ID_HEADER = "nhsd-supplier-id", APIM_CORRELATION_HEADER = "nhsd-correlation-id", DOWNLOAD_URL_TTL_SECONDS = 60 - SNS_TOPIC_ARN = "${module.eventsub.eventsub_topic.arn}", + AMENDMENTS_TOPIC_ARN = "${module.eventsub.amendments_topic.arn}", EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters" } diff --git a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf index 4d8a94d62..124e8fe89 100644 --- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf +++ b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf @@ -91,7 +91,7 @@ data "aws_iam_policy_document" "letter_status_update" { ] resources = [ - module.eventsub.eventsub_topic.arn + module.eventsub.amendments_topic.arn ] } } diff --git a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf index 3dbcb985f..61213791a 100644 --- a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf +++ b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf @@ -18,29 +18,6 @@ module "sqs_letter_updates" { data "aws_iam_policy_document" "letter_updates_queue_policy" { version = "2012-10-17" - statement { - sid = "AllowSNSToSendMessage" - effect = "Allow" - - principals { - type = "Service" - identifiers = ["sns.amazonaws.com"] - } - - actions = [ - "sqs:SendMessage" - ] - - resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-letter-updates-queue" - ] - - condition { - test = "ArnEquals" - variable = "aws:SourceArn" - values = [module.eventsub.eventsub_topic.arn] - } - } statement { sid = "AllowSNSPermissions" @@ -65,7 +42,7 @@ data "aws_iam_policy_document" "letter_updates_queue_policy" { condition { test = "ArnEquals" variable = "aws:SourceArn" - values = [module.eventsub.eventsub_topic.arn] + values = [module.eventsub.eventsub_topic.arn, module.eventsub.amendments_topic.arn] } } } diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf index 317bf9729..424ebf0da 100644 --- a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf +++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf @@ -3,3 +3,9 @@ resource "aws_sns_topic_subscription" "eventsub_sqs_letter_updates" { protocol = "sqs" endpoint = module.sqs_letter_updates.sqs_queue_arn } + +resource "aws_sns_topic_subscription" "amendments_sqs_letter_updates" { + topic_arn = module.eventsub.amendments_topic.arn + protocol = "sqs" + endpoint = module.sqs_letter_updates.sqs_queue_arn +} diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 2439d69f2..1057e93d4 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -179,7 +179,7 @@ variable "enable_sns_delivery_logging" { variable "sns_success_logging_sample_percent" { type = number description = "Enable SNS Delivery Successful Sample Percentage" - default = 0 + default = 100 } variable "enable_api_data_trace" { diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf index 28a7ecfb5..da12c0204 100644 --- a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf +++ b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf @@ -7,3 +7,13 @@ resource "aws_cloudwatch_log_group" "sns_delivery_logging_failure" { kms_key_id = var.kms_key_arn retention_in_days = var.log_retention_in_days } + +resource "aws_cloudwatch_log_group" "amendments_sns_delivery_logging_failure" { + count = var.enable_sns_delivery_logging ? 1 : 0 + + # SNS doesn't allow specifying a log group and is derived as: sns/${region}/${account_id}/${name_of_sns_topic}/Failure + # (for failure logs) + name = "sns/${var.region}/${var.aws_account_id}/${local.csi}-amendments/Failure" + kms_key_id = var.kms_key_arn + retention_in_days = var.log_retention_in_days +} diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf index f760e8561..bac190f3f 100644 --- a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf +++ b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf @@ -7,3 +7,13 @@ resource "aws_cloudwatch_log_group" "sns_delivery_logging_success" { kms_key_id = var.kms_key_arn retention_in_days = var.log_retention_in_days } + +resource "aws_cloudwatch_log_group" "amendments_sns_delivery_logging_success" { + count = var.enable_sns_delivery_logging ? 1 : 0 + + # SNS doesn't allow specifying a log group and is derived as: sns/${region}/${account_id}/${name_of_sns_topic} + # (for success logs) + name = "sns/${var.region}/${var.aws_account_id}/${local.csi}-amendments" + kms_key_id = var.kms_key_arn + retention_in_days = var.log_retention_in_days +} diff --git a/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf b/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf index d296da2dd..c18b30fac 100644 --- a/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf +++ b/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf @@ -39,6 +39,10 @@ data "aws_iam_policy_document" "sns_delivery_logging_cloudwatch" { "${aws_cloudwatch_log_group.sns_delivery_logging_success[0].arn}:log-stream:*", aws_cloudwatch_log_group.sns_delivery_logging_failure[0].arn, "${aws_cloudwatch_log_group.sns_delivery_logging_failure[0].arn}:log-stream:*", + aws_cloudwatch_log_group.amendments_sns_delivery_logging_success[0].arn, + "${aws_cloudwatch_log_group.amendments_sns_delivery_logging_success[0].arn}:log-stream:*", + aws_cloudwatch_log_group.amendments_sns_delivery_logging_failure[0].arn, + "${aws_cloudwatch_log_group.amendments_sns_delivery_logging_failure[0].arn}:log-stream:*", ] } } diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index 6b52a3474..bb7fc9613 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -26,7 +26,7 @@ describe("lambdaEnv", () => { process.env.MAX_LIMIT = "2500"; process.env.QUEUE_URL = "url"; process.env.EVENT_SOURCE = "supplier-api"; - process.env.SNS_TOPIC_ARN = "sns-topic.arn"; + process.env.AMENDMENTS_TOPIC_ARN = "sns-topic.arn"; const { envVars } = require("../env"); @@ -41,7 +41,7 @@ describe("lambdaEnv", () => { MAX_LIMIT: 2500, QUEUE_URL: "url", EVENT_SOURCE: "supplier-api", - SNS_TOPIC_ARN: "sns-topic.arn", + AMENDMENTS_TOPIC_ARN: "sns-topic.arn", }); }); @@ -66,7 +66,7 @@ describe("lambdaEnv", () => { 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"; + process.env.AMENDMENTS_TOPIC_ARN = "sns-topic.arn"; const { envVars } = require("../env"); @@ -80,7 +80,7 @@ describe("lambdaEnv", () => { DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: undefined, EVENT_SOURCE: "supplier-api", - SNS_TOPIC_ARN: "sns-topic.arn", + AMENDMENTS_TOPIC_ARN: "sns-topic.arn", }); }); }); diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index bb1ba609a..170926a7c 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -11,7 +11,7 @@ const EnvVarsSchema = z.object({ MAX_LIMIT: z.coerce.number().int().optional(), QUEUE_URL: z.coerce.string().optional(), EVENT_SOURCE: z.string(), - SNS_TOPIC_ARN: z.string(), + AMENDMENTS_TOPIC_ARN: z.string(), }); export type EnvVars = z.infer; 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 index bd267f1d8..808372e95 100644 --- a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts @@ -117,7 +117,7 @@ describe("createLetterStatusUpdateHandler", () => { i + 1, expect.objectContaining({ input: expect.objectContaining({ - TopicArn: mockedDeps.env.SNS_TOPIC_ARN, + TopicArn: mockedDeps.env.AMENDMENTS_TOPIC_ARN, Message: JSON.stringify( mapLetterToCloudEvent( updateLetterCommands[i] as Letter, diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index 8f2d6f58a..500d1e685 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -31,7 +31,7 @@ export default function createLetterStatusUpdateHandler( deps.env.EVENT_SOURCE, ); await deps.snsClient.send( - buildSnsCommand(letterEvent, deps.env.SNS_TOPIC_ARN), + buildSnsCommand(letterEvent, deps.env.AMENDMENTS_TOPIC_ARN), ); } catch (error) { deps.logger.error( From 530561605631ce091cc335e55d44b42acf18bcd5 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 6 Feb 2026 09:22:07 +0000 Subject: [PATCH 11/12] Temp extra logging --- infrastructure/terraform/components/api/README.md | 2 +- infrastructure/terraform/components/api/variables.tf | 2 +- lambdas/upsert-letter/src/handler/upsert-handler.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index e2e1ae060..d91c28279 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,7 @@ No requirements. | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | | [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | -| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `100` | no | +| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 1057e93d4..2439d69f2 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -179,7 +179,7 @@ variable "enable_sns_delivery_logging" { variable "sns_success_logging_sample_percent" { type = number description = "Enable SNS Delivery Successful Sample Percentage" - default = 100 + default = 0 } variable "enable_api_data_trace" { diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 850ce9f31..38eb98948 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -109,8 +109,9 @@ function resolveSupplierForVariant( return deps.env.VARIANT_MAP[variantId]; } -function parseSNSNotification(record: SQSRecord) { +function parseSNSNotification(record: SQSRecord, deps: Deps) { const notification = JSON.parse(record.body) as Partial; + deps.logger.info({ topicArn: notification.TopicArn }); if ( notification.Type !== "Notification" || typeof notification.Message !== "string" From 0979bcc4c0d3103e4d2b7e62be91f5f90b5216bb Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Fri, 6 Feb 2026 10:01:31 +0000 Subject: [PATCH 12/12] Fix --- lambdas/upsert-letter/src/handler/upsert-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 38eb98948..977829d8a 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -161,7 +161,7 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { const tasks = event.Records.map(async (record) => { try { - const message: string = parseSNSNotification(record); + const message: string = parseSNSNotification(record, deps); const snsEvent = JSON.parse(message);