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/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 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/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, 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"