From 1d5390081f2d69be21fd9120c43190e0eae5498f Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 1 Dec 2025 22:11:43 +0000 Subject: [PATCH 01/37] Add upsert lambda --- .../api/module_lambda_upsert_letter.tf | 2 +- .../src/__test__/letter-repository.test.ts | 138 ++++++++- internal/datastore/src/letter-repository.ts | 109 ++++++- internal/datastore/src/types.ts | 23 ++ lambdas/api-handler/src/contracts/letters.ts | 20 +- .../__tests__/letter-status-update.test.ts | 17 +- .../src/handlers/letter-status-update.ts | 9 +- .../api-handler/src/handlers/patch-letter.ts | 17 +- .../api-handler/src/handlers/post-letters.ts | 4 +- .../api-handler/src/mappers/letter-mapper.ts | 49 ++- .../__tests__/letter-operations.test.ts | 4 +- .../src/services/letter-operations.ts | 54 ++-- lambdas/upsert-letter/package.json | 9 +- .../upsert-letter/src/__tests__/index.test.ts | 17 -- .../src/__tests__/upsert-handler.test.ts | 280 ++++++++++++++++++ .../src/config/__tests__/deps.test.ts | 53 ++++ .../src/config/__tests__/env.test.ts | 47 +++ lambdas/upsert-letter/src/config/deps.ts | 39 +++ lambdas/upsert-letter/src/config/env.ts | 10 + .../upsert-letter/src/contracts/letters.ts | 123 ++++++++ .../src/handler/upsert-handler.ts | 50 ++++ lambdas/upsert-letter/src/index.ts | 15 +- lambdas/upsert-letter/tsconfig.json | 3 +- 23 files changed, 982 insertions(+), 110 deletions(-) delete mode 100644 lambdas/upsert-letter/src/__tests__/index.test.ts create mode 100644 lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts create mode 100644 lambdas/upsert-letter/src/config/__tests__/deps.test.ts create mode 100644 lambdas/upsert-letter/src/config/__tests__/env.test.ts create mode 100644 lambdas/upsert-letter/src/config/deps.ts create mode 100644 lambdas/upsert-letter/src/config/env.ts create mode 100644 lambdas/upsert-letter/src/contracts/letters.ts create mode 100644 lambdas/upsert-letter/src/handler/upsert-handler.ts diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 833e7367..cd6e1785 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -22,7 +22,7 @@ module "upsert_letter" { function_code_base_path = local.aws_lambda_functions_dir_path function_code_dir = "upsert-letter/dist" function_include_common = true - handler_function_name = "handler" + handler_function_name = "upsertLetterHandler" runtime = "nodejs22.x" memory = 128 timeout = 5 diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 6b0c5294..27439a06 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -7,15 +7,14 @@ import { setupDynamoDBContainer, } from "./db"; import { LetterRepository } from "../letter-repository"; -import { Letter } from "../types"; +import { InsertLetter, Letter, UpdateLetter, UpsertLetter } from "../types"; import { LogStream, createTestLogger } from "./logs"; -import { LetterDto } from "../../../../lambdas/api-handler/src/contracts/letters"; function createLetter( supplierId: string, letterId: string, status: Letter["status"] = "PENDING", -): Omit { +): InsertLetter { return { id: letterId, supplierId, @@ -126,14 +125,14 @@ describe("LetterRepository", () => { await letterRepository.putLetter(letter); await checkLetterStatus("supplier1", "letter1", "PENDING"); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "REJECTED", reasonCode: "R01", reasonText: "Reason text", }; - await letterRepository.updateLetterStatus(letterDto); + await letterRepository.updateLetterStatus(updateLetter); const updatedLetter = await letterRepository.getLetterById( "supplier1", @@ -159,7 +158,7 @@ describe("LetterRepository", () => { // Month is zero-indexed in JavaScript Date // Day is one-indexed jest.setSystemTime(new Date(2020, 1, 2)); - const letterDto: LetterDto = { + const letterDto: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", @@ -175,13 +174,13 @@ describe("LetterRepository", () => { }); test("can't update a letter that does not exist", async () => { - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; await expect( - letterRepository.updateLetterStatus(letterDto), + letterRepository.updateLetterStatus(updateLetter), ).rejects.toThrow( "Letter with id letter1 not found for supplier supplier1", ); @@ -193,13 +192,13 @@ describe("LetterRepository", () => { lettersTableName: "nonexistent-table", }); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; await expect( - misconfiguredRepository.updateLetterStatus(letterDto), + misconfiguredRepository.updateLetterStatus(updateLetter), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); @@ -238,12 +237,12 @@ describe("LetterRepository", () => { ); expect(pendingLetters.letters).toHaveLength(2); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; - await letterRepository.updateLetterStatus(letterDto); + await letterRepository.updateLetterStatus(updateLetter); const remainingLetters = await letterRepository.getLettersByStatus( "supplier1", "PENDING", @@ -457,4 +456,119 @@ describe("LetterRepository", () => { ]), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); + + test("successful upsert (update status) returns updated letter", async () => { + const insertLetter: InsertLetter = createLetter("supplier1", "letter1"); + + const existingLetter = await letterRepository.putLetter(insertLetter); + + const result = await letterRepository.upsertLetter({ + id: "letter1", + supplierId: "supplier1", + status: "REJECTED", + reasonCode: "R01", + reasonText: "R01 text", + }); + + expect(result).toEqual( + expect.objectContaining({ + id: "letter1", + status: "REJECTED", + specificationId: "specification1", + groupId: "group1", + reasonCode: "R01", + reasonText: "R01 text", + supplierId: "supplier1", + url: "s3://bucket/letter1.pdf", + supplierStatus: "supplier1#REJECTED", + }), + ); + expect(Date.parse(result.updatedAt)).toBeGreaterThan( + Date.parse(existingLetter.updatedAt), + ); + expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); + expect(result.createdAt).toBe(existingLetter.createdAt); + expect(result.createdAt).toBe(result.supplierStatusSk); + expect(result.ttl).toBe(existingLetter.ttl); + }); + + test("successful upsert (insert) returns created letter", async () => { + const upsertLetter: UpsertLetter = { + id: "letter1", + status: "PENDING", + specificationId: "specification1", + groupId: "group1", + supplierId: "supplier1", + url: "s3://bucket/letter1.pdf", + }; + + const beforeInsert = Date.now() - 1; // widen window + + const result = await letterRepository.upsertLetter(upsertLetter); + + expect(result).toEqual( + expect.objectContaining({ + id: "letter1", + status: "PENDING", + specificationId: "specification1", + groupId: "group1", + supplierId: "supplier1", + url: "s3://bucket/letter1.pdf", + }), + ); + + expect(Date.parse(result.updatedAt)).toBeGreaterThan(beforeInsert); + expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); + expect(result.createdAt).toBe(result.updatedAt); + expect(result.supplierStatusSk).toBe(result.createdAt); + }); + + test("unsuccessful upsert should throw error", async () => { + const mockSend = jest.fn().mockResolvedValue({ Items: null }); + const mockDdbClient = { send: mockSend } as any; + const repo = new LetterRepository( + mockDdbClient, + { debug: jest.fn() } as any, + { lettersTableName: "letters", lettersTtlHours: 1 }, + ); + + await expect( + repo.upsertLetter({ + id: "letter1", + status: "PENDING", + supplierId: "supplier1", + }), + ).rejects.toThrow("upsertLetter: no attributes returned"); + }); + + test("successful upsert without status", async () => { + const insertLetter: InsertLetter = createLetter("supplier1", "letter1"); + + const existingLetter = await letterRepository.putLetter(insertLetter); + + const result = await letterRepository.upsertLetter({ + id: "letter1", + supplierId: "supplier1", + url: "s3://updatedBucket/letter1.pdf", + }); + + expect(result).toEqual( + expect.objectContaining({ + id: "letter1", + status: "PENDING", + specificationId: "specification1", + groupId: "group1", + supplierId: "supplier1", + url: "s3://updatedBucket/letter1.pdf", + supplierStatus: "supplier1#PENDING", + }), + ); + expect(Date.parse(result.updatedAt)).toBeGreaterThan( + Date.parse(existingLetter.updatedAt), + ); + expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); + expect(result.createdAt).toBe(existingLetter.createdAt); + expect(result.createdAt).toBe(result.supplierStatusSk); + expect(result.ttl).toBe(existingLetter.ttl); + }); }); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index e9fd85d0..d76c1d1e 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -9,8 +9,15 @@ import { } from "@aws-sdk/lib-dynamodb"; import { Logger } from "pino"; import { z } from "zod"; -import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from "./types"; -import { LetterDto } from "../../../lambdas/api-handler/src/contracts/letters"; +import { + InsertLetter, + Letter, + LetterBase, + LetterSchema, + LetterSchemaBase, + UpdateLetter, + UpsertLetter, +} from "./types"; export type PagingOptions = Partial<{ exclusiveStartKey: Record; @@ -33,9 +40,7 @@ export class LetterRepository { readonly config: LetterRepositoryConfig, ) {} - async putLetter( - letter: Omit, - ): Promise { + async putLetter(letter: InsertLetter): Promise { const letterDb: Letter = { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, @@ -66,9 +71,7 @@ export class LetterRepository { return LetterSchema.parse(letterDb); } - async putLetterBatch( - letters: Omit[], - ): Promise { + async putLetterBatch(letters: InsertLetter[]): Promise { let lettersDb: Letter[] = []; for (let i = 0; i < letters.length; i++) { const letter = letters[i]; @@ -161,7 +164,7 @@ export class LetterRepository { }; } - async updateLetterStatus(letterToUpdate: LetterDto): Promise { + async updateLetterStatus(letterToUpdate: UpdateLetter): Promise { this.log.debug( `Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`, ); @@ -247,4 +250,92 @@ export class LetterRepository { ); return z.array(LetterSchemaBase).parse(result.Items ?? []); } + + async upsertLetter(upsert: UpsertLetter): Promise { + const now = new Date(); + const ttl = Math.floor( + now.valueOf() / 1000 + 60 * 60 * this.config.lettersTtlHours, + ); + + const setParts: string[] = []; + const exprAttrNames: Record = {}; + const exprAttrValues: Record = {}; + + // updateAt is always updated + setParts.push("updatedAt = :updatedAt"); + exprAttrValues[":updatedAt"] = now.toISOString(); + + // ttl is always updated + setParts.push("#ttl = :ttl"); + exprAttrNames["#ttl"] = "ttl"; + exprAttrValues[":ttl"] = ttl; + + // createdAt only if first time + setParts.push("createdAt = if_not_exists(createdAt, :createdAt)"); + exprAttrValues[":createdAt"] = now.toISOString(); + + // status and related supplierStatus if provided + if (upsert.status !== undefined) { + exprAttrNames["#status"] = "status"; + setParts.push("#status = :status"); + exprAttrValues[":status"] = upsert.status; + + setParts.push("supplierStatus = :supplierStatus"); + exprAttrValues[":supplierStatus"] = + `${upsert.supplierId}#${upsert.status}`; + + // supplierStatusSk should replicate createdAt + setParts.push( + "supplierStatusSk = if_not_exists(supplierStatusSk, :supplierStatusSk)", + ); + exprAttrValues[":supplierStatusSk"] = now.toISOString(); + } + + // fields that could be updated + + if (upsert.specificationId !== undefined) { + setParts.push("specificationId = :specificationId"); + exprAttrValues[":specificationId"] = upsert.specificationId; + } + + if (upsert.url !== undefined) { + setParts.push("#url = :url"); + exprAttrNames["#url"] = "url"; + exprAttrValues[":url"] = upsert.url; + } + + if (upsert.groupId !== undefined) { + setParts.push("groupId = :groupId"); + exprAttrValues[":groupId"] = upsert.groupId; + } + + if (upsert.reasonCode !== undefined) { + setParts.push("reasonCode = :reasonCode"); + exprAttrValues[":reasonCode"] = upsert.reasonCode; + } + if (upsert.reasonText !== undefined) { + setParts.push("reasonText = :reasonText"); + exprAttrValues[":reasonText"] = upsert.reasonText; + } + + const updateExpression = `SET ${setParts.join(", ")}`; + + const command = new UpdateCommand({ + TableName: this.config.lettersTableName, + Key: { supplierId: upsert.supplierId, id: upsert.id }, + UpdateExpression: updateExpression, + ExpressionAttributeNames: exprAttrNames, + ExpressionAttributeValues: exprAttrValues, + ReturnValues: "ALL_NEW", + }); + + const result = await this.ddbClient.send(command); + + if (!result.Attributes) { + throw new Error("upsertLetter: no attributes returned"); + } + + this.log.debug({ exprAttrValues }, `Upsert to letter=${upsert.id}`); + return LetterSchema.parse(result.Attributes); + } } diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 65b6df88..67ca6db3 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -58,6 +58,29 @@ export const LetterSchema = LetterSchemaBase.extend({ export type Letter = z.infer; export type LetterBase = z.infer; +export type InsertLetter = Omit< + Letter, + "ttl" | "supplierStatus" | "supplierStatusSk" +>; +export type UpdateLetter = { + id: string; + supplierId: string; + status: Letter["status"]; + reasonCode?: string; + reasonText?: string; +}; +export type UpsertLetter = { + id: string; + supplierId: string; + // fields that might set/overwrite + status?: Letter["status"]; + specificationId?: string; + groupId?: string; + url?: string; + reasonCode?: string; + reasonText?: string; +}; + export const MISchemaBase = z.object({ id: z.string(), lineItem: z.string(), diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts index 6cab8c70..a1c12114 100644 --- a/lambdas/api-handler/src/contracts/letters.ts +++ b/lambdas/api-handler/src/contracts/letters.ts @@ -15,17 +15,17 @@ export const LetterStatusSchema = z.enum([ 'DELIVERED' ]); -export const LetterDtoSchema = z.object({ - id: z.string(), - status: LetterStatusSchema, - supplierId: z.string(), - specificationId: z.string().optional(), - groupId: z.string().optional(), - reasonCode: z.string().optional(), - reasonText: z.string().optional(), -}).strict(); +export const UpdateLetterSchema = z + .object({ + id: z.string(), + supplierId: z.string(), + status: LetterStatusSchema, + reasonCode: z.string().optional(), + reasonText: z.string().optional(), + }) + .strict(); -export type LetterDto = z.infer; +export type UpdateLetterCommand = z.infer; export const PatchLetterRequestResourceSchema = z.object({ id: z.string(), 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 b2730182..e8ffcfd0 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,9 +1,9 @@ import { Context, SQSEvent, SQSRecord } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; -import { LetterDto } from '../../contracts/letters'; import { S3Client } from '@aws-sdk/client-s3'; import pino from 'pino'; import { LetterRepository } from '@internal/datastore/src'; +import { UpdateLetterCommand } from "../contracts/letters"; import { EnvVars } from '../../config/env'; import { Deps } from '../../config/deps'; import { createLetterStatusUpdateHandler } from '../letter-status-update'; @@ -16,13 +16,11 @@ describe('createLetterStatusUpdateHandler', () => { it('processes letters successfully', async () => { - const lettersToUpdate: LetterDto[] = [ + const lettersToUpdate: UpdateLetterCommand[] = [ { id: 'id1', status: 'REJECTED', supplierId: 's1', - specificationId: 'spec1', - groupId: 'g1', reasonCode: '123', reasonText: 'Reason text' }, @@ -89,11 +87,13 @@ describe('createLetterStatusUpdateHandler', () => { const context = mockDeep(); const callback = jest.fn(); - const letterToUpdate: LetterDto[] = [{ + const letterToUpdate: UpdateLetterCommand[] = [ + { id: 'id1', status: 'ACCEPTED', supplierId: 's1' - }]; + }, + ]; const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); await letterStatusUpdateHandler(buildEvent(letterToUpdate), context, callback); @@ -108,9 +108,8 @@ describe('createLetterStatusUpdateHandler', () => { }); }); -function buildEvent(lettersToUpdate: LetterDto[]): SQSEvent { - - const records: Partial[] = lettersToUpdate.map(letter => { +function buildEvent(lettersToUpdate: UpdateLetterCommand[]): SQSEvent { + const records: Partial[] = lettersToUpdate.map((letter) => { return { messageId: `mid-${letter.id}`, body: JSON.stringify(letter), diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index 6e8e9241..9b51893a 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -1,14 +1,15 @@ -import { SQSEvent, SQSHandler, SQSRecord } from 'aws-lambda'; -import { LetterDto, LetterDtoSchema } from '../contracts/letters'; +import { SQSEvent, SQSHandler } from 'aws-lambda'; +import { UpdateLetterCommand, UpdateLetterSchema } from '../contracts/letters'; import { Deps } from '../config/deps'; +import { mapToUpdateLetter } from '../mappers/letter-mapper'; export function createLetterStatusUpdateHandler(deps: Deps): SQSHandler { return async ( event: SQSEvent ) => { const tasks = event.Records.map( async (message) => { try { - const letterToUpdate: LetterDto = LetterDtoSchema.parse(JSON.parse(message.body)); - await deps.letterRepo.updateLetterStatus(letterToUpdate); + const letterToUpdate: UpdateLetterCommand = UpdateLetterSchema.parse(JSON.parse(message.body)); + await deps.letterRepo.updateLetterStatus(mapToUpdateLetter(letterToUpdate)); } catch (error) { deps.logger.error({ err: error, diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index 8ad221e5..1b9d13ee 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,12 +1,16 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; +import { + PatchLetterRequest, + PatchLetterRequestSchema, + UpdateLetterCommand, +} from "../contracts/letters"; +import { mapToUpdateCommand } from "../mappers/letter-mapper"; import { enqueueLetterUpdateRequests } from '../services/letter-operations'; -import { LetterDto, PatchLetterRequest, PatchLetterRequestSchema } from '../contracts/letters'; import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { processError } from '../mappers/error-mapper'; import { assertNotEmpty } from '../utils/validation'; import { extractCommonIds } from '../utils/commonIds'; -import { mapPatchLetterToDto } from '../mappers/letter-mapper'; import type { Deps } from "../config/deps"; @@ -36,13 +40,16 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { else throw error; } - const letterToUpdate: LetterDto = mapPatchLetterToDto(patchLetterRequest, commonIds.value.supplierId); + const updateLetterCommand: UpdateLetterCommand = mapToUpdateCommand( + patchLetterRequest, + commonIds.value.supplierId, + ); - if (letterToUpdate.id !== letterId) { + if (updateLetterCommand.id !== letterId) { throw new ValidationError(ApiErrorDetail.InvalidRequestLetterIdsMismatch); } - await enqueueLetterUpdateRequests([letterToUpdate], commonIds.value.correlationId, deps); + await enqueueLetterUpdateRequests([updateLetterCommand], commonIds.value.correlationId, deps); return { statusCode: 202, diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 26a3ffc1..116f6b71 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -1,10 +1,10 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; +import { mapToUpdateCommands } from "../mappers/letter-mapper"; import type { Deps } from "../config/deps"; import { ApiErrorDetail } from '../contracts/errors'; import { PostLettersRequest, PostLettersRequestSchema } from '../contracts/letters'; import { ValidationError } from '../errors'; import { processError } from '../mappers/error-mapper'; -import { mapPostLettersToDtoArray } from '../mappers/letter-mapper'; import { enqueueLetterUpdateRequests } from '../services/letter-operations'; import { extractCommonIds } from '../utils/commonIds'; import { assertNotEmpty, requireEnvVar } from '../utils/validation'; @@ -45,7 +45,7 @@ export function createPostLettersHandler(deps: Deps): APIGatewayProxyHandler { } await enqueueLetterUpdateRequests( - mapPostLettersToDtoArray(postLettersRequest, commonIds.value.supplierId), + mapToUpdateCommands(postLettersRequest, commonIds.value.supplierId), commonIds.value.correlationId, deps ); diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index 44fb1ad2..0d9af04d 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,7 +1,25 @@ -import { LetterBase, LetterStatus } from "@internal/datastore"; -import { GetLetterResponse, GetLetterResponseSchema, GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema, PostLettersRequest, PostLettersRequestResource } from '../contracts/letters'; +import { LetterBase, LetterStatus, UpdateLetter } from "@internal/datastore"; +import { + GetLetterResponse, + GetLetterResponseSchema, + GetLettersResponse, + GetLettersResponseSchema, + PatchLetterRequest, + PatchLetterResponse, + PatchLetterResponseSchema, + PostLettersRequest, + PostLettersRequestResource, + UpdateLetterCommand, +} from "../contracts/letters"; -export function mapPatchLetterToDto(request: PatchLetterRequest, supplierId: string): LetterDto { +// -------------------------- +// Map request to command +// -------------------------- + +export function mapToUpdateCommand( + request: PatchLetterRequest, + supplierId: string, +): UpdateLetterCommand { return { id: request.data.id, supplierId, @@ -11,7 +29,10 @@ export function mapPatchLetterToDto(request: PatchLetterRequest, supplierId: str }; } -export function mapPostLettersToDtoArray(request: PostLettersRequest, supplierId: string): LetterDto[] { +export function mapToUpdateCommands( + request: PostLettersRequest, + supplierId: string, +): UpdateLetterCommand[] { return request.data.map( (letterToUpdate: PostLettersRequestResource) => ({ id: letterToUpdate.id, supplierId, @@ -21,6 +42,26 @@ export function mapPostLettersToDtoArray(request: PostLettersRequest, supplierId })); } +// --------------------------------------------- +// 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 +// --------------------------------------------- + export function mapToPatchLetterResponse(letter: LetterBase): PatchLetterResponse { return PatchLetterResponseSchema.parse({ data: letterToResourceResponse(letter) diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 88ea9433..28633572 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,6 +1,6 @@ import { Letter, LetterRepository } from '@internal/datastore'; import { Deps } from '../../config/deps'; -import { LetterDto, PostLettersRequest } from '../../contracts/letters'; +import { UpdateLetterCommand } from "../../contracts/letters"; import { enqueueLetterUpdateRequests, getLetterById, getLetterDataUrl, getLettersForSupplier } from '../letter-operations'; import pino from 'pino'; @@ -174,7 +174,7 @@ describe('enqueueLetterUpdateRequests function', () => { jest.clearAllMocks(); }); - const lettersToUpdate: LetterDto[] = [ + const lettersToUpdate: UpdateLetterCommand[] = [ { id: 'id1', status: 'REJECTED', diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 4d559dda..0cc33668 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -1,6 +1,6 @@ import { LetterBase, LetterRepository } from '@internal/datastore'; import { NotFoundError } from '../errors'; -import { LetterDto } from '../contracts/letters'; +import { UpdateLetterCommand } from "../contracts/letters"; import { ApiErrorDetail } from '../contracts/errors'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; @@ -58,28 +58,36 @@ async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: number) return await getSignedUrl(s3Client, command, { expiresIn: expiry }); } -export async function enqueueLetterUpdateRequests(updateRequests: LetterDto[], correlationId: string, deps: Deps) { - - const tasks = updateRequests.map(async (request: LetterDto) => { - try { - const command = new SendMessageCommand({ - QueueUrl: deps.env.QUEUE_URL, - MessageAttributes: { - CorrelationId: { DataType: 'String', StringValue: correlationId }, - }, - MessageBody: JSON.stringify(request), - }); - await deps.sqsClient.send(command); - } catch (err) { - deps.logger.error({ - err, - correlationId: correlationId, - letterId: request.id, - letterStatus: request.status, - supplierId: request.supplierId - }, 'Error enqueuing letter status update'); - } - }); +export async function enqueueLetterUpdateRequests( + updateLetterCommands: UpdateLetterCommand[], + correlationId: string, + deps: Deps, +) { + const tasks = updateLetterCommands.map( + async (request: UpdateLetterCommand) => { + try { + const command = new SendMessageCommand({ + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { DataType: "String", StringValue: correlationId }, + }, + MessageBody: JSON.stringify(request), + }); + await deps.sqsClient.send(command); + } catch (error) { + deps.logger.error( + { + err: error, + correlationId, + letterId: request.id, + letterStatus: request.status, + supplierId: request.supplierId, + }, + "Error enqueuing letter status update", + ); + } + }, + ); await Promise.all(tasks); } diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index a34856af..58f10e9a 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -1,10 +1,15 @@ { "dependencies": { - "esbuild": "^0.24.0" + "@aws-sdk/client-dynamodb": "^3.858.0", + "@aws-sdk/lib-dynamodb": "^3.858.0", + "@internal/datastore": "*", + "@types/aws-lambda": "^8.10.148", + "esbuild": "^0.24.0", + "pino": "^9.7.0", + "zod": "^4.1.11" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", - "@types/aws-lambda": "^8.10.148", "@types/jest": "^30.0.0", "jest": "^30.2.0", "jest-mock-extended": "^4.0.0", diff --git a/lambdas/upsert-letter/src/__tests__/index.test.ts b/lambdas/upsert-letter/src/__tests__/index.test.ts deleted file mode 100644 index 768b0f2c..00000000 --- a/lambdas/upsert-letter/src/__tests__/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { handler } from '../index'; -import type { Context } from 'aws-lambda'; -import { mockDeep } from 'jest-mock-extended'; - -describe('event-logging Lambda', () => { - it('logs the input event and returns 200', async () => { - const event = { foo: 'bar' }; - const context = mockDeep(); - const callback = jest.fn(); - const result = await handler(event, context, callback); - - expect(result).toEqual({ - statusCode: 200, - body: 'Event logged', - }); - }); -}); diff --git a/lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts new file mode 100644 index 00000000..be09d6bf --- /dev/null +++ b/lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts @@ -0,0 +1,280 @@ +import { SQSEvent } from "aws-lambda"; +import pino from "pino"; +import { LetterRepository } from "internal/datastore/src"; +import createUpsertLetterHandler from "../handler/upsert-handler"; +import { Deps } from "../config/deps"; +import { EnvVars } from "../config/env"; +import { LetterRequestPreparedEvent } from "../contracts/letters"; + +function createValidEvent( + overrides: Partial = {}, +): LetterRequestPreparedEvent { + // minimal valid event matching the prepared letter schema + const now = new Date().toISOString(); + + return { + specversion: "1.0", + id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", + source: "/data-plane/letter-rendering/test", + subject: "client/client1/letter-request/letterRequest1", + type: "uk.nhs.notify.letter-rendering.letter-request.PREPARED.v1", + time: now, + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.PREPARED.1.0.0.schema.json", + dataschemaversion: "1.0.0", + data: { + domainId: overrides.domainId ?? "letter1", + supplierId: overrides.supplierId ?? "supplier1", + specificationId: overrides.specificationId ?? "spec1", + requestId: overrides.requestId ?? "request1", + requestItemId: overrides.requestItemId ?? "requestItem1", + requestItemPlanId: overrides.requestItemPlanId ?? "requestItemPlan1", + clientId: overrides.clientId ?? "client1", + campaignId: overrides.campaignId ?? "campaign1", + templateId: overrides.templateId ?? "template1", + url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", + sha256Hash: + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", + createdAt: now, + pageCount: 1, + status: "PREPARED", + urgency: "STANDARD", + }, + traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", + recordedtime: now, + severitynumber: 1, + }; +} + +describe("createUpsertLetterHandler", () => { + const mockedDeps: jest.Mocked = { + letterRepo: { + upsertLetter: jest.fn(), + } as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME", + LETTER_TTL_HOURS: 12_960, + } as unknown as EnvVars, + } as Deps; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("processes all records successfully and returns no batch failures", async () => { + const evt: SQSEvent = { + Records: [ + { + messageId: "msg1", + receiptHandle: "rh1", + body: JSON.stringify(createValidEvent()), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + { + messageId: "msg2", + receiptHandle: "rh2", + body: JSON.stringify( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45614", + domainId: "letter2", + url: "s3://letterDataBucket/letter2.pdf", + }), + ), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(0); + + expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + + const firstArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + .calls[0][0]; + expect(firstArg.id).toBe("letter1"); + expect(firstArg.supplierId).toBe("supplier1"); + expect(firstArg.specificationId).toBe("spec1"); + expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf"); + expect(firstArg.status).toBe("PENDING"); + expect(firstArg.groupId).toBe("client1campaign1template1"); + + const secondArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + .calls[1][0]; + expect(secondArg.id).toBe("letter2"); + expect(secondArg.supplierId).toBe("supplier1"); + expect(secondArg.specificationId).toBe("spec1"); + expect(secondArg.url).toBe("s3://letterDataBucket/letter2.pdf"); + expect(secondArg.status).toBe("PENDING"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + }); + + test("invalid JSON body produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + { + messageId: "bad-json", + receiptHandle: "rh1", + body: "this-is-not-json", + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json"); + + expect(mockedDeps.logger.error).toHaveBeenCalled(); + expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); + }); + + test("invalid schema produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + { + messageId: "bad-schema", + receiptHandle: "rh1", + body: JSON.stringify({ not: "the expected shape" }), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); + + expect(mockedDeps.logger.error).toHaveBeenCalled(); + expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); + }); + + test("repository throwing for one record causes that message to be returned in batch failures while others succeed", async () => { + (mockedDeps.letterRepo.upsertLetter as jest.Mock) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("ddb error")); + + const evt: SQSEvent = { + Records: [ + { + messageId: "ok-msg", + receiptHandle: "rh1", + body: JSON.stringify( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45615", + data: { domainId: "ok" }, + }), + ), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + { + messageId: "fail-msg", + receiptHandle: "rh2", + body: JSON.stringify( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45616", + data: { domainId: "ok" }, + }), + ), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("fail-msg"); + + expect(mockedDeps.logger.error).toHaveBeenCalled(); + }); +}); diff --git a/lambdas/upsert-letter/src/config/__tests__/deps.test.ts b/lambdas/upsert-letter/src/config/__tests__/deps.test.ts new file mode 100644 index 00000000..a532da6f --- /dev/null +++ b/lambdas/upsert-letter/src/config/__tests__/deps.test.ts @@ -0,0 +1,53 @@ +import type { Deps } from "lambdas/upsert-letter/src/config/deps"; + +describe("createDependenciesContainer", () => { + const env = { + LETTERS_TABLE_NAME: "LettersTable", + LETTER_TTL_HOURS: 12_960, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + // pino + jest.mock("pino", () => ({ + __esModule: true, + default: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + })), + })); + + // Repo client + jest.mock("@internal/datastore", () => ({ + LetterRepository: jest.fn(), + })); + + // Env + jest.mock("../env", () => ({ envVars: env })); + }); + + test("constructs deps and wires repository config correctly", async () => { + // get current mock instances + const pinoMock = jest.requireMock("pino"); + const { LetterRepository } = jest.requireMock("@internal/datastore"); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createDependenciesContainer } = require("../deps"); + const deps: Deps = createDependenciesContainer(); + + expect(pinoMock.default).toHaveBeenCalledTimes(1); + + expect(LetterRepository).toHaveBeenCalledTimes(1); + const letterRepoCtorArgs = LetterRepository.mock.calls[0]; + expect(letterRepoCtorArgs[2]).toEqual({ + lettersTableName: "LettersTable", + lettersTtlHours: 12_960, + }); + + expect(deps.env).toEqual(env); + }); +}); diff --git a/lambdas/upsert-letter/src/config/__tests__/env.test.ts b/lambdas/upsert-letter/src/config/__tests__/env.test.ts new file mode 100644 index 00000000..ac932848 --- /dev/null +++ b/lambdas/upsert-letter/src/config/__tests__/env.test.ts @@ -0,0 +1,47 @@ +import { ZodError } from "zod"; +/* eslint-disable @typescript-eslint/no-require-imports */ +/* Allow require imports to enable re-import of modules */ + +describe("lambdaEnv", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); // Clears cached modules + process.env = { ...OLD_ENV }; // Clone original env + }); + + afterAll(() => { + process.env = OLD_ENV; // Restore + }); + + it("should load all environment variables successfully", () => { + process.env.LETTERS_TABLE_NAME = "letters-table"; + process.env.LETTER_TTL_HOURS = "12960"; + + const { envVars } = require("../env"); + + expect(envVars).toEqual({ + LETTERS_TABLE_NAME: "letters-table", + LETTER_TTL_HOURS: 12_960, + }); + }); + + it("should throw if a required env var is missing", () => { + process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var + process.env.LETTER_TTL_HOURS = "12960"; + + expect(() => require("../env")).toThrow(ZodError); + }); + + it("should not throw if optional are not set", () => { + process.env.LETTERS_TABLE_NAME = "letters-table"; + process.env.LETTER_TTL_HOURS = "12960"; + + const { envVars } = require("../env"); + + expect(envVars).toEqual({ + LETTERS_TABLE_NAME: "letters-table", + LETTER_TTL_HOURS: 12_960, + }); + }); +}); diff --git a/lambdas/upsert-letter/src/config/deps.ts b/lambdas/upsert-letter/src/config/deps.ts new file mode 100644 index 00000000..7320af6f --- /dev/null +++ b/lambdas/upsert-letter/src/config/deps.ts @@ -0,0 +1,39 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import pino from "pino"; +import { LetterRepository } from "@internal/datastore"; +import { EnvVars, envVars } from "./env"; + +export type Deps = { + letterRepo: LetterRepository; + logger: pino.Logger; + env: EnvVars; +}; + +function createDocumentClient(): DynamoDBDocumentClient { + const ddbClient = new DynamoDBClient({}); + return DynamoDBDocumentClient.from(ddbClient); +} + +function createLetterRepository( + log: pino.Logger, + // eslint-disable-next-line @typescript-eslint/no-shadow + envVars: EnvVars, +): LetterRepository { + const config = { + lettersTableName: envVars.LETTERS_TABLE_NAME, + lettersTtlHours: envVars.LETTER_TTL_HOURS, + }; + + return new LetterRepository(createDocumentClient(), log, config); +} + +export function createDependenciesContainer(): Deps { + const log = pino(); + + return { + letterRepo: createLetterRepository(log, envVars), + logger: log, + env: envVars, + }; +} diff --git a/lambdas/upsert-letter/src/config/env.ts b/lambdas/upsert-letter/src/config/env.ts new file mode 100644 index 00000000..7655d314 --- /dev/null +++ b/lambdas/upsert-letter/src/config/env.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +const EnvVarsSchema = z.object({ + LETTERS_TABLE_NAME: z.string(), + LETTER_TTL_HOURS: z.coerce.number().int(), +}); + +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); diff --git a/lambdas/upsert-letter/src/contracts/letters.ts b/lambdas/upsert-letter/src/contracts/letters.ts new file mode 100644 index 00000000..43f597aa --- /dev/null +++ b/lambdas/upsert-letter/src/contracts/letters.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +const dateTimeRegex = + // eslint-disable-next-line security/detect-unsafe-regex, sonarjs/regex-complexity + /^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$/; + +export const LetterRequestPreparedEventSchema = z + .object({ + specversion: z.literal("1.0"), + + id: z + .string() + .min(1) + .regex( + // uuid OR special all-zero OR all-f case, as per JSON Schema pattern + /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/, + ), + + source: z.string().regex( + // eslint-disable-next-line security/detect-unsafe-regex + /^\/data-plane\/letter-rendering(?:\/.*)?$/, + "source must start with /data-plane/letter-rendering", + ), + + subject: z.string().regex( + // eslint-disable-next-line security/detect-unsafe-regex + /^client\/[a-z0-9-]+\/letter-request\/[^/]+(?:\/.*)?/, + "subject must match client//letter-request/", + ), + + type: z.literal( + "uk.nhs.notify.letter-rendering.letter-request.PREPARED.v1", + ), + + time: z.iso + .datetime() + // optional extra strict RFC3339 check, as per the JSON Schema + .regex(dateTimeRegex, "time must be RFC3339 / date-time"), + + datacontenttype: z.literal("application/json").optional(), + + dataschema: z + .string() + .regex( + /^https:\/\/notify\.nhs\.uk\/cloudevents\/schemas\/letter-rendering\/letter-request\.PREPARED\.1\.\d+\.\d+\.schema\.json$/, + ), + + dataschemaversion: z.string().regex(/^1\.\d+\.\d+$/), + + data: z.object({ + domainId: z.string(), + clientId: z.string(), + campaignId: z.string().optional(), + specificationId: z.string(), + requestId: z.string(), + requestItemId: z.string(), + requestItemPlanId: z.string(), + supplierId: z.string(), + templateId: z.string().optional(), + url: z.url(), + sha256Hash: z.string(), + createdAt: z.iso + .datetime() + .regex(dateTimeRegex, "createdAt must be RFC3339 / date-time"), + pageCount: z.number().int().min(1), + status: z.literal("PREPARED"), + urgency: z.enum(["STANDARD", "URGENT"]), + }), + + traceparent: z + .string() + .min(1) + // regex as per JSON schema + .regex( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/, + "traceparent must be valid w3c traceparent", + ), + + tracestate: z.string().optional(), + + partitionkey: z + .string() + .min(1) + .max(64) + // regex as per JSON schema + .regex(/^[a-z0-9-]+$/) + .optional(), + + recordedtime: z.iso + .datetime() + .regex(dateTimeRegex, "recordedtime must be RFC3339 / date-time"), + + sampledrate: z.number().int().min(1).max(9_007_199_254_740_991).optional(), + + sequence: z + .string() + // as per JSON schema + .regex(/^\d{20}$/, "sequence must be a zero-padded 20 digit string") + .optional(), + + severitytext: z + .enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"]) + .optional(), + + severitynumber: z.number().int().min(0).max(5), + + dataclassification: z + .enum(["public", "internal", "confidential", "restricted"]) + .optional(), + + dataregulation: z + .enum(["GDPR", "HIPAA", "PCI-DSS", "ISO-27001", "NIST-800-53", "CCPA"]) + .optional(), + + datacategory: z + .enum(["non-sensitive", "standard", "sensitive", "special-category"]) + .optional(), + }) + .strict(); + +export type LetterRequestPreparedEvent = z.infer< + typeof LetterRequestPreparedEventSchema +>; diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts new file mode 100644 index 00000000..4206da4e --- /dev/null +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -0,0 +1,50 @@ +import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; +import { UpsertLetter } from "@internal/datastore"; +import { + LetterRequestPreparedEvent, + LetterRequestPreparedEventSchema, +} from "../contracts/letters"; +import { Deps } from "../config/deps"; + +function mapToUpsertLetter( + upsertRequest: LetterRequestPreparedEvent, +): UpsertLetter { + return { + id: upsertRequest.data.domainId, + supplierId: upsertRequest.data.supplierId, + status: "PENDING", + specificationId: upsertRequest.data.specificationId, + groupId: + upsertRequest.data.clientId + + upsertRequest.data.campaignId + + upsertRequest.data.templateId, + url: upsertRequest.data.url, + // TODO CCM-12997 source + // TODO CCM-12997 urgency + // TODO CCM-12997 queueVisibility + }; +} + +export default function createUpsertLetterHandler(deps: Deps): SQSHandler { + return async (event: SQSEvent) => { + const batchItemFailures: SQSBatchItemFailure[] = []; + + const tasks = event.Records.map(async (record) => { + try { + const upsertRequest: LetterRequestPreparedEvent = + LetterRequestPreparedEventSchema.parse(JSON.parse(record.body)); + + const letterToUpsert: UpsertLetter = mapToUpsertLetter(upsertRequest); + + await deps.letterRepo.upsertLetter(letterToUpsert); + } catch (error) { + deps.logger.error({ err: error }, "Error processing upsert"); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + }); + + await Promise.all(tasks); + + return { batchItemFailures }; + }; +} diff --git a/lambdas/upsert-letter/src/index.ts b/lambdas/upsert-letter/src/index.ts index 3b5a5bff..bb4b41c9 100644 --- a/lambdas/upsert-letter/src/index.ts +++ b/lambdas/upsert-letter/src/index.ts @@ -1,10 +1,7 @@ -// Replace me with the actual code for your Lambda function -import { Handler } from 'aws-lambda'; +import { createDependenciesContainer } from "./config/deps"; +import createUpsertLetterHandler from "./handler/upsert-handler"; -export const handler: Handler = async (event) => { - console.log('Received event:', event); - return { - statusCode: 200, - body: 'Event logged', - }; -}; +const container = createDependenciesContainer(); + +const upsertLetterHandler = createUpsertLetterHandler(container); +export default upsertLetterHandler; diff --git a/lambdas/upsert-letter/tsconfig.json b/lambdas/upsert-letter/tsconfig.json index ea37d696..24902365 100644 --- a/lambdas/upsert-letter/tsconfig.json +++ b/lambdas/upsert-letter/tsconfig.json @@ -1,5 +1,6 @@ { - "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": {}, + "extends": "../../tsconfig.base.json", "include": [ "src/**/*", "jest.config.ts" From 4334697c525275143f5ec622f56551edeb9f6dda Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 2 Dec 2025 15:53:45 +0000 Subject: [PATCH 02/37] Fix names --- .../terraform/components/api/module_lambda_upsert_letter.tf | 2 +- lambdas/upsert-letter/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index cd6e1785..b938679c 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -1,7 +1,7 @@ module "upsert_letter" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip" - function_name = "upsert-letter" + function_name = "upsert_letter" description = "Update or Insert the letter data in the letters table" aws_account_id = var.aws_account_id diff --git a/lambdas/upsert-letter/src/index.ts b/lambdas/upsert-letter/src/index.ts index bb4b41c9..867a0c85 100644 --- a/lambdas/upsert-letter/src/index.ts +++ b/lambdas/upsert-letter/src/index.ts @@ -3,5 +3,5 @@ import createUpsertLetterHandler from "./handler/upsert-handler"; const container = createDependenciesContainer(); -const upsertLetterHandler = createUpsertLetterHandler(container); +export const upsertLetterHandler = createUpsertLetterHandler(container); export default upsertLetterHandler; From 132326d9ba49ffe091fef042d1f62e36cb09bfd1 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 2 Dec 2025 16:37:11 +0000 Subject: [PATCH 03/37] Fix unit test --- internal/datastore/src/__test__/letter-repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 27439a06..9c4312a5 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -568,7 +568,7 @@ describe("LetterRepository", () => { ); expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); expect(result.createdAt).toBe(existingLetter.createdAt); - expect(result.createdAt).toBe(result.supplierStatusSk); + expect(result.supplierStatusSk).toBe(existingLetter.supplierStatusSk); expect(result.ttl).toBe(existingLetter.ttl); }); }); From 036c3194a019095e4a29b06af1bf62866bf82592 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 2 Dec 2025 17:45:51 +0000 Subject: [PATCH 04/37] Fix unit test dates --- .../src/__test__/letter-repository.test.ts | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 9c4312a5..3f277609 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -458,17 +458,22 @@ describe("LetterRepository", () => { }); test("successful upsert (update status) returns updated letter", async () => { - const insertLetter: InsertLetter = createLetter("supplier1", "letter1"); - - const existingLetter = await letterRepository.putLetter(insertLetter); + jest.useFakeTimers(); + jest.setSystemTime(new Date(2020, 0, 1)); + const letter: InsertLetter = createLetter("supplier1", "letter1"); + const existingLetter: Letter = await letterRepository.putLetter(letter); - const result = await letterRepository.upsertLetter({ + const updateLetterStatus: UpsertLetter = { id: "letter1", supplierId: "supplier1", status: "REJECTED", reasonCode: "R01", reasonText: "R01 text", - }); + }; + + jest.setSystemTime(new Date(2020, 0, 2)); + const result: Letter = + await letterRepository.upsertLetter(updateLetterStatus); expect(result).toEqual( expect.objectContaining({ @@ -486,14 +491,13 @@ describe("LetterRepository", () => { expect(Date.parse(result.updatedAt)).toBeGreaterThan( Date.parse(existingLetter.updatedAt), ); - expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); expect(result.createdAt).toBe(existingLetter.createdAt); expect(result.createdAt).toBe(result.supplierStatusSk); - expect(result.ttl).toBe(existingLetter.ttl); + expect(result.ttl).toBeGreaterThan(existingLetter.ttl); }); - test("successful upsert (insert) returns created letter", async () => { - const upsertLetter: UpsertLetter = { + test("successful upsert (insert letter) returns created letter", async () => { + const insertLetter: UpsertLetter = { id: "letter1", status: "PENDING", specificationId: "specification1", @@ -502,9 +506,11 @@ describe("LetterRepository", () => { url: "s3://bucket/letter1.pdf", }; - const beforeInsert = Date.now() - 1; // widen window + const nowTest: Date = new Date(2020, 0, 1); + jest.useFakeTimers(); + jest.setSystemTime(nowTest); - const result = await letterRepository.upsertLetter(upsertLetter); + const result: Letter = await letterRepository.upsertLetter(insertLetter); expect(result).toEqual( expect.objectContaining({ @@ -517,39 +523,23 @@ describe("LetterRepository", () => { }), ); - expect(Date.parse(result.updatedAt)).toBeGreaterThan(beforeInsert); - expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); + expect(Date.parse(result.updatedAt)).toBe(nowTest.valueOf()); expect(result.createdAt).toBe(result.updatedAt); expect(result.supplierStatusSk).toBe(result.createdAt); + expect(result.ttl).toBe(new Date(2020, 0, 1, 1).valueOf() / 1000); }); - test("unsuccessful upsert should throw error", async () => { - const mockSend = jest.fn().mockResolvedValue({ Items: null }); - const mockDdbClient = { send: mockSend } as any; - const repo = new LetterRepository( - mockDdbClient, - { debug: jest.fn() } as any, - { lettersTableName: "letters", lettersTtlHours: 1 }, - ); - - await expect( - repo.upsertLetter({ - id: "letter1", - status: "PENDING", - supplierId: "supplier1", - }), - ).rejects.toThrow("upsertLetter: no attributes returned"); - }); - - test("successful upsert without status", async () => { + test("successful upsert without status change (update url)", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2020, 0, 1)); const insertLetter: InsertLetter = createLetter("supplier1", "letter1"); - const existingLetter = await letterRepository.putLetter(insertLetter); + jest.setSystemTime(new Date(2020, 0, 2)); const result = await letterRepository.upsertLetter({ id: "letter1", supplierId: "supplier1", - url: "s3://updatedBucket/letter1.pdf", + url: "s3://updateToPdf", }); expect(result).toEqual( @@ -559,16 +549,33 @@ describe("LetterRepository", () => { specificationId: "specification1", groupId: "group1", supplierId: "supplier1", - url: "s3://updatedBucket/letter1.pdf", + url: "s3://updateToPdf", supplierStatus: "supplier1#PENDING", }), ); expect(Date.parse(result.updatedAt)).toBeGreaterThan( Date.parse(existingLetter.updatedAt), ); - expect(Date.parse(result.updatedAt)).toBeLessThan(Date.now()); expect(result.createdAt).toBe(existingLetter.createdAt); - expect(result.supplierStatusSk).toBe(existingLetter.supplierStatusSk); - expect(result.ttl).toBe(existingLetter.ttl); + expect(result.createdAt).toBe(result.supplierStatusSk); + expect(result.ttl).toBeGreaterThan(existingLetter.ttl); + }); + + test("unsuccessful upsert should throw error", async () => { + const mockSend = jest.fn().mockResolvedValue({ Items: null }); + const mockDdbClient = { send: mockSend } as any; + const repo = new LetterRepository( + mockDdbClient, + { debug: jest.fn() } as any, + { lettersTableName: "letters", lettersTtlHours: 1 }, + ); + + await expect( + repo.upsertLetter({ + id: "letter1", + status: "PENDING", + supplierId: "supplier1", + }), + ).rejects.toThrow("upsertLetter: no attributes returned"); }); }); From d6602c34fa83a419b968a46a522513e74176ba13 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 2 Dec 2025 17:52:51 +0000 Subject: [PATCH 05/37] Fix lambda handler name export --- lambdas/upsert-letter/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/upsert-letter/src/index.ts b/lambdas/upsert-letter/src/index.ts index 867a0c85..0c2fc9e2 100644 --- a/lambdas/upsert-letter/src/index.ts +++ b/lambdas/upsert-letter/src/index.ts @@ -3,5 +3,5 @@ import createUpsertLetterHandler from "./handler/upsert-handler"; const container = createDependenciesContainer(); +// eslint-disable-next-line import-x/prefer-default-export export const upsertLetterHandler = createUpsertLetterHandler(container); -export default upsertLetterHandler; From 92d4d266ea54f00f802786148d5634c068238901 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 4 Dec 2025 16:23:53 +0000 Subject: [PATCH 06/37] Add event-schemas-letter-rendering --- .devcontainer/devcontainer.json | 3 +- .../terraform/components/api/README.md | 1 + .../api/module_lambda_upsert_letter.tf | 13 +- lambdas/upsert-letter/package.json | 1 + .../src/config/__tests__/env.test.ts | 27 ++-- lambdas/upsert-letter/src/config/env.ts | 13 ++ .../upsert-letter/src/contracts/letters.ts | 123 ------------------ .../__tests__/upsert-handler.test.ts | 30 +++-- .../src/handler/upsert-handler.ts | 39 +++++- package-lock.json | 22 +++- 10 files changed, 111 insertions(+), 161 deletions(-) delete mode 100644 lambdas/upsert-letter/src/contracts/letters.ts rename lambdas/upsert-letter/src/{ => handler}/__tests__/upsert-handler.test.ts (92%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 91e5c470..5e056149 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -83,7 +83,8 @@ }, "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", - "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached" + "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached", + "source=${localEnv:HOME}/.npmrc,target=/home/vscode/.aws,type=bind,consistency=cached" ], "name": "Devcontainer", "postCreateCommand": "scripts/devcontainer/postcreatecommand.sh" diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 150af054..60fb96f6 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -30,6 +30,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 | +| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplier_id = string, spec_id = string }))` |
{
"lv1": {
"spec_id": "spec1",
"supplier_id": "supplier1"
},
"lv2": {
"spec_id": "spec2",
"supplier_id": "supplier1"
},
"lv3": {
"spec_id": "spec3",
"supplier_id": "supplier2"
}
}
| no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index b938679c..1899fc99 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -35,7 +35,18 @@ module "upsert_letter" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_lambda_env_vars, {}) + lambda_env_vars = merge(local.common_lambda_env_vars, { + VARIANT_MAP = jsonencode(var.variant_map) + }) +} + +variable "variant_map" { + type = map(object({ supplier_id = string, spec_id = string })) + default = { + "lv1" = { supplier_id = "supplier1", spec_id = "spec1" }, + "lv2" = { supplier_id = "supplier1", spec_id = "spec2" }, + "lv3" = { supplier_id = "supplier2", spec_id = "spec3" } + } } data "aws_iam_policy_document" "upsert_letter_lambda" { diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index 58f10e9a..e5993ea7 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -3,6 +3,7 @@ "@aws-sdk/client-dynamodb": "^3.858.0", "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", "@types/aws-lambda": "^8.10.148", "esbuild": "^0.24.0", "pino": "^9.7.0", diff --git a/lambdas/upsert-letter/src/config/__tests__/env.test.ts b/lambdas/upsert-letter/src/config/__tests__/env.test.ts index ac932848..3c3de230 100644 --- a/lambdas/upsert-letter/src/config/__tests__/env.test.ts +++ b/lambdas/upsert-letter/src/config/__tests__/env.test.ts @@ -17,31 +17,32 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.LETTERS_TABLE_NAME = "letters-table"; process.env.LETTER_TTL_HOURS = "12960"; + process.env.VARIANT_MAP = `{ + "lv1": { + "supplierId": "supplier1", + "specId": "spec1" + } + }`; const { envVars } = require("../env"); expect(envVars).toEqual({ LETTERS_TABLE_NAME: "letters-table", LETTER_TTL_HOURS: 12_960, + VARIANT_MAP: { + lv1: { + supplierId: "supplier1", + specId: "spec1", + }, + }, }); }); it("should throw if a required env var is missing", () => { - process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var + process.env.LETTERS_TABLE_NAME = "table"; process.env.LETTER_TTL_HOURS = "12960"; + process.env.VARIANT_MAP = undefined; expect(() => require("../env")).toThrow(ZodError); }); - - it("should not throw if optional are not set", () => { - process.env.LETTERS_TABLE_NAME = "letters-table"; - process.env.LETTER_TTL_HOURS = "12960"; - - const { envVars } = require("../env"); - - expect(envVars).toEqual({ - LETTERS_TABLE_NAME: "letters-table", - LETTER_TTL_HOURS: 12_960, - }); - }); }); diff --git a/lambdas/upsert-letter/src/config/env.ts b/lambdas/upsert-letter/src/config/env.ts index 7655d314..ef527258 100644 --- a/lambdas/upsert-letter/src/config/env.ts +++ b/lambdas/upsert-letter/src/config/env.ts @@ -1,8 +1,21 @@ import { z } from "zod"; +const LetterVariantSchema = z.record( + z.string(), + z.object({ + supplierId: z.string(), + specId: z.string(), + }), +); +export type LetterVariant = z.infer; + const EnvVarsSchema = z.object({ LETTERS_TABLE_NAME: z.string(), LETTER_TTL_HOURS: z.coerce.number().int(), + VARIANT_MAP: z.string().transform((str, _) => { + const parsed = JSON.parse(str); + return LetterVariantSchema.parse(parsed); + }), }); export type EnvVars = z.infer; diff --git a/lambdas/upsert-letter/src/contracts/letters.ts b/lambdas/upsert-letter/src/contracts/letters.ts deleted file mode 100644 index 43f597aa..00000000 --- a/lambdas/upsert-letter/src/contracts/letters.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { z } from "zod"; - -const dateTimeRegex = - // eslint-disable-next-line security/detect-unsafe-regex, sonarjs/regex-complexity - /^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$/; - -export const LetterRequestPreparedEventSchema = z - .object({ - specversion: z.literal("1.0"), - - id: z - .string() - .min(1) - .regex( - // uuid OR special all-zero OR all-f case, as per JSON Schema pattern - /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/, - ), - - source: z.string().regex( - // eslint-disable-next-line security/detect-unsafe-regex - /^\/data-plane\/letter-rendering(?:\/.*)?$/, - "source must start with /data-plane/letter-rendering", - ), - - subject: z.string().regex( - // eslint-disable-next-line security/detect-unsafe-regex - /^client\/[a-z0-9-]+\/letter-request\/[^/]+(?:\/.*)?/, - "subject must match client//letter-request/", - ), - - type: z.literal( - "uk.nhs.notify.letter-rendering.letter-request.PREPARED.v1", - ), - - time: z.iso - .datetime() - // optional extra strict RFC3339 check, as per the JSON Schema - .regex(dateTimeRegex, "time must be RFC3339 / date-time"), - - datacontenttype: z.literal("application/json").optional(), - - dataschema: z - .string() - .regex( - /^https:\/\/notify\.nhs\.uk\/cloudevents\/schemas\/letter-rendering\/letter-request\.PREPARED\.1\.\d+\.\d+\.schema\.json$/, - ), - - dataschemaversion: z.string().regex(/^1\.\d+\.\d+$/), - - data: z.object({ - domainId: z.string(), - clientId: z.string(), - campaignId: z.string().optional(), - specificationId: z.string(), - requestId: z.string(), - requestItemId: z.string(), - requestItemPlanId: z.string(), - supplierId: z.string(), - templateId: z.string().optional(), - url: z.url(), - sha256Hash: z.string(), - createdAt: z.iso - .datetime() - .regex(dateTimeRegex, "createdAt must be RFC3339 / date-time"), - pageCount: z.number().int().min(1), - status: z.literal("PREPARED"), - urgency: z.enum(["STANDARD", "URGENT"]), - }), - - traceparent: z - .string() - .min(1) - // regex as per JSON schema - .regex( - /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/, - "traceparent must be valid w3c traceparent", - ), - - tracestate: z.string().optional(), - - partitionkey: z - .string() - .min(1) - .max(64) - // regex as per JSON schema - .regex(/^[a-z0-9-]+$/) - .optional(), - - recordedtime: z.iso - .datetime() - .regex(dateTimeRegex, "recordedtime must be RFC3339 / date-time"), - - sampledrate: z.number().int().min(1).max(9_007_199_254_740_991).optional(), - - sequence: z - .string() - // as per JSON schema - .regex(/^\d{20}$/, "sequence must be a zero-padded 20 digit string") - .optional(), - - severitytext: z - .enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"]) - .optional(), - - severitynumber: z.number().int().min(0).max(5), - - dataclassification: z - .enum(["public", "internal", "confidential", "restricted"]) - .optional(), - - dataregulation: z - .enum(["GDPR", "HIPAA", "PCI-DSS", "ISO-27001", "NIST-800-53", "CCPA"]) - .optional(), - - datacategory: z - .enum(["non-sensitive", "standard", "sensitive", "special-category"]) - .optional(), - }) - .strict(); - -export type LetterRequestPreparedEvent = z.infer< - typeof LetterRequestPreparedEventSchema ->; diff --git a/lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts similarity index 92% rename from lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts rename to lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts index be09d6bf..f65fcca0 100644 --- a/lambdas/upsert-letter/src/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -1,10 +1,10 @@ import { SQSEvent } from "aws-lambda"; import pino from "pino"; import { LetterRepository } from "internal/datastore/src"; -import createUpsertLetterHandler from "../handler/upsert-handler"; -import { Deps } from "../config/deps"; -import { EnvVars } from "../config/env"; -import { LetterRequestPreparedEvent } from "../contracts/letters"; +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import createUpsertLetterHandler from "../upsert-handler"; +import { Deps } from "../../config/deps"; +import { EnvVars } from "../../config/env"; function createValidEvent( overrides: Partial = {}, @@ -17,15 +17,14 @@ function createValidEvent( id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", source: "/data-plane/letter-rendering/test", subject: "client/client1/letter-request/letterRequest1", - type: "uk.nhs.notify.letter-rendering.letter-request.PREPARED.v1", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", time: now, dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.PREPARED.1.0.0.schema.json", + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", dataschemaversion: "1.0.0", data: { domainId: overrides.domainId ?? "letter1", - supplierId: overrides.supplierId ?? "supplier1", - specificationId: overrides.specificationId ?? "spec1", + letterVariantId: overrides.letterVariantId ?? "lv1", requestId: overrides.requestId ?? "request1", requestItemId: overrides.requestItemId ?? "requestItem1", requestItemPlanId: overrides.requestItemPlanId ?? "requestItemPlan1", @@ -38,11 +37,12 @@ function createValidEvent( createdAt: now, pageCount: 1, status: "PREPARED", - urgency: "STANDARD", }, traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", recordedtime: now, - severitynumber: 1, + severitynumber: 2, + severitytext: "INFO", + plane: "data", }; } @@ -51,11 +51,17 @@ describe("createUpsertLetterHandler", () => { letterRepo: { upsertLetter: jest.fn(), } as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + logger: { error: jest.fn() } as unknown as pino.Logger, env: { LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME", LETTER_TTL_HOURS: 12_960, - } as unknown as EnvVars, + VARIANT_MAP: { + lv1: { + supplierId: "supplier1", + specId: "spec1", + }, + }, + } as EnvVars, } as Deps; beforeEach(() => { diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 4206da4e..52dac948 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -1,30 +1,41 @@ import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; import { UpsertLetter } from "@internal/datastore"; import { + $LetterRequestPreparedEvent, LetterRequestPreparedEvent, - LetterRequestPreparedEventSchema, -} from "../contracts/letters"; +} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { ZodError } from "zod"; import { Deps } from "../config/deps"; +type SupplierSpec = { supplierId: string; specId: string }; + function mapToUpsertLetter( upsertRequest: LetterRequestPreparedEvent, + supplier: string, + spec: string, ): UpsertLetter { return { id: upsertRequest.data.domainId, - supplierId: upsertRequest.data.supplierId, + supplierId: supplier, status: "PENDING", - specificationId: upsertRequest.data.specificationId, + specificationId: spec, groupId: upsertRequest.data.clientId + upsertRequest.data.campaignId + upsertRequest.data.templateId, url: upsertRequest.data.url, // TODO CCM-12997 source - // TODO CCM-12997 urgency // TODO CCM-12997 queueVisibility }; } +function resolveSupplierForVariant( + variantId: string, + deps: Deps, +): SupplierSpec { + return deps.env.VARIANT_MAP[variantId]; +} + export default function createUpsertLetterHandler(deps: Deps): SQSHandler { return async (event: SQSEvent) => { const batchItemFailures: SQSBatchItemFailure[] = []; @@ -32,12 +43,26 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { const tasks = event.Records.map(async (record) => { try { const upsertRequest: LetterRequestPreparedEvent = - LetterRequestPreparedEventSchema.parse(JSON.parse(record.body)); + $LetterRequestPreparedEvent.parse(JSON.parse(record.body)); - const letterToUpsert: UpsertLetter = mapToUpsertLetter(upsertRequest); + const supplierSpec: SupplierSpec = resolveSupplierForVariant( + upsertRequest.data.letterVariantId, + deps, + ); + const letterToUpsert: UpsertLetter = mapToUpsertLetter( + upsertRequest, + supplierSpec.supplierId, + supplierSpec.specId, + ); await deps.letterRepo.upsertLetter(letterToUpsert); } catch (error) { + if (error instanceof ZodError) { + deps.logger.error( + { issues: error.issues }, + "Error parsing letter event in upsert", + ); + } deps.logger.error({ err: error }, "Error processing upsert"); batchItemFailures.push({ itemIdentifier: record.messageId }); } diff --git a/package-lock.json b/package-lock.json index 326f15e6..acff7d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", @@ -2338,11 +2338,17 @@ "name": "nhs-notify-supplier-api-upsert-letter", "version": "0.0.1", "dependencies": { - "esbuild": "^0.24.0" + "@aws-sdk/client-dynamodb": "^3.858.0", + "@aws-sdk/lib-dynamodb": "^3.858.0", + "@internal/datastore": "*", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", + "@types/aws-lambda": "^8.10.148", + "esbuild": "^0.24.0", + "pino": "^9.7.0", + "zod": "^4.1.11" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", - "@types/aws-lambda": "^8.10.148", "@types/jest": "^30.0.0", "jest": "^30.2.0", "jest-mock-extended": "^4.0.0", @@ -7380,6 +7386,15 @@ "node": ">=8.6.0" } }, + "node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering": { + "version": "1.1.5", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/1.1.5/7949805ee5a21ff934f798e73822774c64366677", + "integrity": "sha512-9Rx3dkYdVqwBbeE3YpJkdiu54fHyHFfeLFZGNRUuxX/dsi0obnJDxdPfBk07aK9jopDZmuVvKQS1O6Sigl+SuQ==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.11" + } + }, "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { "resolved": "internal/events", "link": true @@ -9818,7 +9833,6 @@ "version": "8.10.158", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.158.tgz", "integrity": "sha512-v/n2WsL1ksRKigfqZ9ff7ANobfT3t/T8kI8UOiur98tREwFulv9lRv+pDrocGPWOe3DpD2Y2GKRO+OiyxwgaCQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { From b0405358ed9a933edab1baef7ec8e60593d29cef Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:20:44 +0000 Subject: [PATCH 07/37] Add GitHub NPM registry configuration to .npmrc --- .npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..15cc4735 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@nhsdigital:registry=https://npm.pkg.github.com From ffb8d5c5f61eae863ed412e5659394391357122d Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:27:29 +0000 Subject: [PATCH 08/37] Add version check and Node.js setup to CI workflow --- .github/workflows/stage-1-commit.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index d1c137b1..15c1fdf5 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -272,6 +272,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -290,3 +291,25 @@ jobs: echo "Error: Event Schema package has changed, but new version ($local_version) is not a valid increment from latest version on main branch ($main_version)." exit 1 fi + + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + + - name: Check if local version differs from latest published version + id: check-version + run: | + published_version=$(npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') + echo "Published version: $published_version" + + local_version=$(jq -r '.version' internal/events/package.json) + echo "Local version: $local_version" + + if [[ $local_version = $published_version ]]; then + echo "Local version is the same as the latest published version - skipping publish" + echo "version_changed=false" >> $GITHUB_OUTPUT + else + echo "Local version is different to the latest published version - publishing new version" + echo "version_changed=true" >> $GITHUB_OUTPUT + fi From c307fc981ff307d95ebacca29abcb37781f7bef0 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:32:27 +0000 Subject: [PATCH 09/37] TO REVERT AFTER TEST: conditional check for event schema version update --- .github/workflows/stage-1-commit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 15c1fdf5..a464f734 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -268,7 +268,7 @@ jobs: check-schema-version-change: name: Check event schema version has been updated needs: detect-event-schema-package-changes - if: needs.detect-event-schema-package-changes.outputs.changed == 'true' + #if: needs.detect-event-schema-package-changes.outputs.changed == 'true' #REVERT runs-on: ubuntu-latest permissions: contents: read From 46c8402e96c98b556d8be25f66f60dc40bce5643 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:39:39 +0000 Subject: [PATCH 10/37] Refactor event schema version checks in CI workflow --- .github/workflows/stage-1-commit.yaml | 52 ++++++++++----------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index a464f734..0a4094b6 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -237,42 +237,13 @@ jobs: echo "Detected package version $version in main branch" echo "main_version=$version" >> $GITHUB_OUTPUT -# check-schemas-generated: -# name: Check event schemas have been regenerated -# needs: detect-event-schema-package-changes -# if: needs.detect-event-schema-package-changes.outputs.changed == 'true' -# runs-on: ubuntu-latest -# permissions: -# contents: read -# steps: -# - name: "Checkout code" -# uses: actions/checkout@v4 -# -# - name: "Cache node_modules" -# uses: actions/cache@v4 -# with: -# path: | -# **/node_modules -# key: ${{ runner.os }}-node-${{ inputs.nodejs_version }}-${{ hashFiles('**/package-lock.json') }} -# restore-keys: | -# ${{ runner.os }}-node-${{ inputs.nodejs_version }}- -# -# - name: "Re-generate schemas" -# run: | -# npm ci -# npm --workspace internal/events run gen:jsonschema -# -# - name: Check for schema changes -# run: git diff --quiet internal/events/schemas - check-schema-version-change: name: Check event schema version has been updated needs: detect-event-schema-package-changes - #if: needs.detect-event-schema-package-changes.outputs.changed == 'true' #REVERT + if: needs.detect-event-schema-package-changes.outputs.changed == 'true' runs-on: ubuntu-latest permissions: contents: read - packages: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -292,12 +263,26 @@ jobs: exit 1 fi + check-event-schemas-version-change: + name: Check for event schemas package version change + needs: detect-event-schema-package-changes + # if: needs.detect-event-schema-package-changes.outputs.changed == 'true' + outputs: + version_changed: ${{ steps.check-version.outputs.version_changed }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + - name: Setup NodeJS uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - - name: Check if local version differs from latest published version + - name: check if local version differs from latest published version id: check-version run: | published_version=$(npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') @@ -307,9 +292,10 @@ jobs: echo "Local version: $local_version" if [[ $local_version = $published_version ]]; then - echo "Local version is the same as the latest published version - skipping publish" + echo "ERROR: Local version is the same as the latest published version, but event schemas have changed" echo "version_changed=false" >> $GITHUB_OUTPUT + exit 1 else - echo "Local version is different to the latest published version - publishing new version" + echo "Local version is different to the latest published version - a new version will be published" echo "version_changed=true" >> $GITHUB_OUTPUT fi From f1444fd68bf5166167e11d042843bcacc4fcb784 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:44:29 +0000 Subject: [PATCH 11/37] Add GitHub NPM registry URL to Node.js setup in workflows --- .github/actions/build-docs/action.yml | 1 + .github/actions/build-libraries/action.yml | 1 + .github/actions/build-proxies/action.yml | 1 + .github/actions/build-sandbox/action.yml | 1 + .github/actions/build-sdk/action.yml | 1 + .github/actions/build-server/action.yml | 1 + .github/workflows/pr_closed.yaml | 2 ++ .github/workflows/stage-1-commit.yaml | 1 + 8 files changed, 9 insertions(+) diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 68d873e0..937c5ce8 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm cli install working-directory: . run: npm ci diff --git a/.github/actions/build-libraries/action.yml b/.github/actions/build-libraries/action.yml index 84a25b11..cfd082d7 100644 --- a/.github/actions/build-libraries/action.yml +++ b/.github/actions/build-libraries/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index f1cc4859..d2227bfe 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -38,6 +38,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: "Cache node_modules" uses: actions/cache@v4 diff --git a/.github/actions/build-sandbox/action.yml b/.github/actions/build-sandbox/action.yml index 5023383c..8d25938f 100644 --- a/.github/actions/build-sandbox/action.yml +++ b/.github/actions/build-sandbox/action.yml @@ -13,6 +13,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index 944bdd00..e0b95d70 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-server/action.yml b/.github/actions/build-server/action.yml index fa2213fe..c11e2c61 100644 --- a/.github/actions/build-server/action.yml +++ b/.github/actions/build-server/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/workflows/pr_closed.yaml b/.github/workflows/pr_closed.yaml index 1c6c8c1e..ab6a353d 100644 --- a/.github/workflows/pr_closed.yaml +++ b/.github/workflows/pr_closed.yaml @@ -83,6 +83,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version @@ -116,6 +117,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: "Install dependencies" run: npm ci - name: "Run provider contract tests" diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 0a4094b6..a45b86d2 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -281,6 +281,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version From 59d8846742b829e55e8685e052c5f59a85e8688f Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 10:57:31 +0000 Subject: [PATCH 12/37] Log npm response --- .github/workflows/stage-1-commit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index a45b86d2..5ba825c5 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -286,6 +286,7 @@ jobs: - name: check if local version differs from latest published version id: check-version run: | + npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json published_version=$(npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') echo "Published version: $published_version" From d7a665c08a13326930f72f469fb5b351fcb27e31 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:08:39 +0000 Subject: [PATCH 13/37] Add NODE_AUTH_TOKEN to GitHub Actions environment for NPM access --- .github/workflows/stage-1-commit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 5ba825c5..a900ab40 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -273,6 +273,8 @@ jobs: permissions: contents: read packages: read + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v5.0.0 From 5741a86f4b10bfeeef7004734f2b829150400c55 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:12:16 +0000 Subject: [PATCH 14/37] Revert "Add GitHub NPM registry URL to Node.js setup in workflows" This reverts commit f1444fd68bf5166167e11d042843bcacc4fcb784. --- .github/actions/build-docs/action.yml | 1 - .github/actions/build-libraries/action.yml | 1 - .github/actions/build-proxies/action.yml | 1 - .github/actions/build-sandbox/action.yml | 1 - .github/actions/build-sdk/action.yml | 1 - .github/actions/build-server/action.yml | 1 - .github/workflows/pr_closed.yaml | 2 -- .github/workflows/stage-1-commit.yaml | 1 - 8 files changed, 9 deletions(-) diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 937c5ce8..68d873e0 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -12,7 +12,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: 'https://npm.pkg.github.com' - name: Npm cli install working-directory: . run: npm ci diff --git a/.github/actions/build-libraries/action.yml b/.github/actions/build-libraries/action.yml index cfd082d7..84a25b11 100644 --- a/.github/actions/build-libraries/action.yml +++ b/.github/actions/build-libraries/action.yml @@ -12,7 +12,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index d2227bfe..f1cc4859 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -38,7 +38,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' - name: "Cache node_modules" uses: actions/cache@v4 diff --git a/.github/actions/build-sandbox/action.yml b/.github/actions/build-sandbox/action.yml index 8d25938f..5023383c 100644 --- a/.github/actions/build-sandbox/action.yml +++ b/.github/actions/build-sandbox/action.yml @@ -13,7 +13,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index e0b95d70..944bdd00 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -12,7 +12,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-server/action.yml b/.github/actions/build-server/action.yml index c11e2c61..fa2213fe 100644 --- a/.github/actions/build-server/action.yml +++ b/.github/actions/build-server/action.yml @@ -12,7 +12,6 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/workflows/pr_closed.yaml b/.github/workflows/pr_closed.yaml index ab6a353d..1c6c8c1e 100644 --- a/.github/workflows/pr_closed.yaml +++ b/.github/workflows/pr_closed.yaml @@ -83,7 +83,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version @@ -117,7 +116,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' - name: "Install dependencies" run: npm ci - name: "Run provider contract tests" diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index a900ab40..d2c8f6a0 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -283,7 +283,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version From c427d03828d2893698c990b1092be0e6bc5f6ede Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:24:34 +0000 Subject: [PATCH 15/37] Move NODE_AUTH_TOKEN to npm view step in CI workflow --- .github/workflows/stage-1-commit.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index d2c8f6a0..f91425bd 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -273,8 +273,6 @@ jobs: permissions: contents: read packages: read - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v5.0.0 @@ -286,7 +284,10 @@ jobs: - name: check if local version differs from latest published version id: check-version + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + cat .npmrc npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json published_version=$(npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') echo "Published version: $published_version" From 8981a44857318c33a3b9073b7d816ae2b3e035d1 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:25:32 +0000 Subject: [PATCH 16/37] Reapply "Add GitHub NPM registry URL to Node.js setup in workflows" This reverts commit 5741a86f4b10bfeeef7004734f2b829150400c55. --- .github/actions/build-docs/action.yml | 1 + .github/actions/build-libraries/action.yml | 1 + .github/actions/build-proxies/action.yml | 1 + .github/actions/build-sandbox/action.yml | 1 + .github/actions/build-sdk/action.yml | 1 + .github/actions/build-server/action.yml | 1 + .github/workflows/pr_closed.yaml | 2 ++ .github/workflows/stage-1-commit.yaml | 1 + 8 files changed, 9 insertions(+) diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 68d873e0..937c5ce8 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm cli install working-directory: . run: npm ci diff --git a/.github/actions/build-libraries/action.yml b/.github/actions/build-libraries/action.yml index 84a25b11..cfd082d7 100644 --- a/.github/actions/build-libraries/action.yml +++ b/.github/actions/build-libraries/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index f1cc4859..d2227bfe 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -38,6 +38,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: "Cache node_modules" uses: actions/cache@v4 diff --git a/.github/actions/build-sandbox/action.yml b/.github/actions/build-sandbox/action.yml index 5023383c..8d25938f 100644 --- a/.github/actions/build-sandbox/action.yml +++ b/.github/actions/build-sandbox/action.yml @@ -13,6 +13,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index 944bdd00..e0b95d70 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/actions/build-server/action.yml b/.github/actions/build-server/action.yml index fa2213fe..c11e2c61 100644 --- a/.github/actions/build-server/action.yml +++ b/.github/actions/build-server/action.yml @@ -12,6 +12,7 @@ runs: - uses: actions/setup-node@v4 with: node-version: 22 + registry-url: 'https://npm.pkg.github.com' - name: Npm install working-directory: . diff --git a/.github/workflows/pr_closed.yaml b/.github/workflows/pr_closed.yaml index 1c6c8c1e..ab6a353d 100644 --- a/.github/workflows/pr_closed.yaml +++ b/.github/workflows/pr_closed.yaml @@ -83,6 +83,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version @@ -116,6 +117,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: "Install dependencies" run: npm ci - name: "Run provider contract tests" diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index f91425bd..0fd93e7b 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -281,6 +281,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' - name: check if local version differs from latest published version id: check-version From fbf2e9df69c9328c6006bbfa6be35353b1be5c40 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:31:45 +0000 Subject: [PATCH 17/37] Add NODE_AUTH_TOKEN to NPM install steps in workflows --- .github/actions/build-docs/action.yml | 2 ++ .github/actions/build-libraries/action.yml | 2 ++ .github/actions/build-proxies/action.yml | 2 ++ .github/actions/build-sandbox/action.yml | 2 ++ .github/actions/build-sdk/action.yml | 2 ++ .github/actions/build-server/action.yml | 2 ++ .github/workflows/manual-proxy-environment-deploy.yaml | 2 ++ .github/workflows/pr_closed.yaml | 4 ++++ .github/workflows/stage-1-commit.yaml | 2 -- .github/workflows/stage-2-test.yaml | 8 ++++++++ 10 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 937c5ce8..d78ad631 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -15,6 +15,8 @@ runs: registry-url: 'https://npm.pkg.github.com' - name: Npm cli install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash - name: Setup Ruby diff --git a/.github/actions/build-libraries/action.yml b/.github/actions/build-libraries/action.yml index cfd082d7..87bf5d20 100644 --- a/.github/actions/build-libraries/action.yml +++ b/.github/actions/build-libraries/action.yml @@ -16,6 +16,8 @@ runs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index d2227bfe..882c79b7 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -51,6 +51,8 @@ runs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-sandbox/action.yml b/.github/actions/build-sandbox/action.yml index 8d25938f..a913705e 100644 --- a/.github/actions/build-sandbox/action.yml +++ b/.github/actions/build-sandbox/action.yml @@ -17,6 +17,8 @@ runs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index e0b95d70..3c8d743a 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -16,6 +16,8 @@ runs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-server/action.yml b/.github/actions/build-server/action.yml index c11e2c61..ad9c179c 100644 --- a/.github/actions/build-server/action.yml +++ b/.github/actions/build-server/action.yml @@ -16,6 +16,8 @@ runs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/workflows/manual-proxy-environment-deploy.yaml b/.github/workflows/manual-proxy-environment-deploy.yaml index dfbcb43a..57fbd10a 100644 --- a/.github/workflows/manual-proxy-environment-deploy.yaml +++ b/.github/workflows/manual-proxy-environment-deploy.yaml @@ -36,6 +36,8 @@ jobs: - name: Npm install working-directory: . + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci shell: bash diff --git a/.github/workflows/pr_closed.yaml b/.github/workflows/pr_closed.yaml index ab6a353d..00d9fba6 100644 --- a/.github/workflows/pr_closed.yaml +++ b/.github/workflows/pr_closed.yaml @@ -119,6 +119,8 @@ jobs: node-version: ${{ inputs.nodejs_version }} registry-url: 'https://npm.pkg.github.com' - name: "Install dependencies" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci - name: "Run provider contract tests" run: make test-contract @@ -147,6 +149,8 @@ jobs: registry-url: 'https://npm.pkg.github.com' - name: Install dependencies + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm ci - name: Publish to GitHub Packages diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 0fd93e7b..c9bef129 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -288,8 +288,6 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cat .npmrc - npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json published_version=$(npm view @nhsdigital/nhs-notify-event-schemas-supplier-api --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') echo "Published version: $published_version" diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 29c5cd0c..b41f6b9d 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -57,6 +57,8 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ inputs.nodejs_version }}- - name: "Repo setup" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm ci - name: "Generate dependencies" @@ -79,6 +81,8 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ inputs.nodejs_version }}- - name: "Repo setup" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm ci - name: "Generate dependencies" @@ -115,6 +119,8 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ inputs.nodejs_version }}- - name: "Repo setup" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm ci - name: "Generate dependencies" @@ -139,6 +145,8 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ inputs.nodejs_version }}- - name: "Repo setup" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | npm ci - name: "Generate dependencies" From 113bbe512535c21f78007cc12e0a5c56d4eecf09 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:35:27 +0000 Subject: [PATCH 18/37] Re-enable conditional check on published package check --- .github/workflows/stage-1-commit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index c9bef129..44921ed9 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -266,7 +266,7 @@ jobs: check-event-schemas-version-change: name: Check for event schemas package version change needs: detect-event-schema-package-changes - # if: needs.detect-event-schema-package-changes.outputs.changed == 'true' + if: needs.detect-event-schema-package-changes.outputs.changed == 'true' outputs: version_changed: ${{ steps.check-version.outputs.version_changed }} runs-on: ubuntu-latest From 032e48f8ee64c232555963ecd0e8cd021d2fa148 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 12:53:22 +0000 Subject: [PATCH 19/37] Add NODE_AUTH_TOKEN input for GitHub package registry access in workflows --- .github/actions/build-docs/action.yml | 5 ++++- .github/actions/build-proxies/action.yml | 3 +++ .github/actions/build-sandbox/action.yml | 6 +++++- .github/actions/build-sdk/action.yml | 5 ++++- .github/workflows/manual-proxy-environment-deploy.yaml | 1 + .github/workflows/stage-3-build.yaml | 3 +++ 6 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index d78ad631..8b887bf3 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -4,6 +4,9 @@ inputs: version: description: "Version number" required: true + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: "composite" steps: @@ -16,7 +19,7 @@ runs: - name: Npm cli install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash - name: Setup Ruby diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index 882c79b7..3198cfe7 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -28,6 +28,9 @@ inputs: nodejs_version: description: "Node.js version, set by the CI/CD pipeline workflow" required: true + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: composite diff --git a/.github/actions/build-sandbox/action.yml b/.github/actions/build-sandbox/action.yml index a913705e..5bcef84c 100644 --- a/.github/actions/build-sandbox/action.yml +++ b/.github/actions/build-sandbox/action.yml @@ -4,6 +4,10 @@ inputs: version: description: "Version number" required: true + + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: composite @@ -18,7 +22,7 @@ runs: - name: Npm install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index 3c8d743a..1231b2c2 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -4,6 +4,9 @@ inputs: version: description: "Version number" required: true + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: "composite" steps: @@ -17,7 +20,7 @@ runs: - name: Npm install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash diff --git a/.github/workflows/manual-proxy-environment-deploy.yaml b/.github/workflows/manual-proxy-environment-deploy.yaml index 57fbd10a..03399d5d 100644 --- a/.github/workflows/manual-proxy-environment-deploy.yaml +++ b/.github/workflows/manual-proxy-environment-deploy.yaml @@ -89,3 +89,4 @@ jobs: runId: "${{ github.run_id }}" buildSandbox: ${{ inputs.build_sandbox }} releaseVersion: ${{ github.ref_name }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index e21d7286..5e6cdf5d 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -51,6 +51,7 @@ jobs: uses: ./.github/actions/build-docs with: version: "${{ inputs.version }}" + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} artefact-sdks: name: "Build SDKs" @@ -63,6 +64,7 @@ jobs: uses: ./.github/actions/build-sdk with: version: "${{ inputs.version }}" + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Take out for now - might add again in the future # artefact-servers: @@ -133,3 +135,4 @@ jobs: buildSandbox: true releaseVersion: ${{ github.head_ref || github.ref_name }} nodejs_version: ${{ inputs.nodejs_version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f24f37a43cf68055029eeb10367a6014682c6b5e Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Fri, 5 Dec 2025 13:00:26 +0000 Subject: [PATCH 20/37] Add packages:read to permissions --- .github/actions/build-libraries/action.yml | 5 ++++- .github/actions/build-proxies/action.yml | 2 +- .github/actions/build-server/action.yml | 5 ++++- .github/workflows/manual-proxy-environment-deploy.yaml | 1 + .github/workflows/stage-2-test.yaml | 1 + .github/workflows/stage-3-build.yaml | 2 ++ 6 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/actions/build-libraries/action.yml b/.github/actions/build-libraries/action.yml index 87bf5d20..14ac5231 100644 --- a/.github/actions/build-libraries/action.yml +++ b/.github/actions/build-libraries/action.yml @@ -4,6 +4,9 @@ inputs: version: description: "Version number" required: true + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: "composite" steps: @@ -17,7 +20,7 @@ runs: - name: Npm install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-proxies/action.yml b/.github/actions/build-proxies/action.yml index 3198cfe7..5dcb872d 100644 --- a/.github/actions/build-proxies/action.yml +++ b/.github/actions/build-proxies/action.yml @@ -55,7 +55,7 @@ runs: - name: Npm install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash diff --git a/.github/actions/build-server/action.yml b/.github/actions/build-server/action.yml index ad9c179c..c077fa3b 100644 --- a/.github/actions/build-server/action.yml +++ b/.github/actions/build-server/action.yml @@ -4,6 +4,9 @@ inputs: version: description: "Version number" required: true + NODE_AUTH_TOKEN: + description: "Token for access to github package registry" + required: true runs: using: "composite" steps: @@ -17,7 +20,7 @@ runs: - name: Npm install working-directory: . env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ inputs.NODE_AUTH_TOKEN }} run: npm ci shell: bash diff --git a/.github/workflows/manual-proxy-environment-deploy.yaml b/.github/workflows/manual-proxy-environment-deploy.yaml index 03399d5d..c8ca20fe 100644 --- a/.github/workflows/manual-proxy-environment-deploy.yaml +++ b/.github/workflows/manual-proxy-environment-deploy.yaml @@ -21,6 +21,7 @@ on: permissions: contents: read + packages: read jobs: deploy-environment: diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index b41f6b9d..d8646f20 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -39,6 +39,7 @@ env: permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout + packages: read # This is required for downloading from GitHub Package Registry jobs: check-generated-dependencies: diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 5e6cdf5d..474b9094 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -39,6 +39,8 @@ on: permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout + packages: read # This is required for downloading from GitHub Package Registry + jobs: artefact-jekyll-docs: name: "Build Docs" From 6400400ca9079289c4ca5c08e19f1c015d970447 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 5 Dec 2025 15:28:47 +0000 Subject: [PATCH 21/37] delete old test --- .../upsert-letter/src/__tests__/index.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 lambdas/upsert-letter/src/__tests__/index.test.ts diff --git a/lambdas/upsert-letter/src/__tests__/index.test.ts b/lambdas/upsert-letter/src/__tests__/index.test.ts deleted file mode 100644 index d29b864b..00000000 --- a/lambdas/upsert-letter/src/__tests__/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Context } from "aws-lambda"; -import { mockDeep } from "jest-mock-extended"; -import handler from ".."; - -describe("event-logging Lambda", () => { - it("logs the input event and returns 200", async () => { - const event = { foo: "bar" }; - const context = mockDeep(); - const callback = jest.fn(); - const result = await handler(event, context, callback); - - expect(result).toEqual({ - statusCode: 200, - body: "Event logged", - }); - }); -}); From 4001e32bab78b6ed6a15192d8910c94816ece003 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 5 Dec 2025 16:08:54 +0000 Subject: [PATCH 22/37] fix trivy scan step --- .github/workflows/stage-1-commit.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 44921ed9..32440d18 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -72,7 +72,7 @@ jobs: needs: detect-terraform-changes if: needs.detect-terraform-changes.outputs.terraform_changed == 'true' permissions: - contents: write + contents: write steps: - name: "Checkout code" uses: actions/checkout@v5 @@ -157,8 +157,6 @@ jobs: uses: actions/checkout@v5 - name: "Setup ASDF" uses: asdf-vm/actions/setup@v4 - - name: "Perform Setup" - uses: ./.github/actions/setup - name: "Trivy Scan" uses: ./.github/actions/trivy count-lines-of-code: @@ -281,7 +279,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' + registry-url: "https://npm.pkg.github.com" - name: check if local version differs from latest published version id: check-version From 66eb971618ab3ea5724c4c1a0489b8c4c7ba8523 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 8 Dec 2025 15:14:41 +0000 Subject: [PATCH 23/37] Fix gh pkg registry test stage --- .github/workflows/stage-2-test.yaml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index d8646f20..912a53ad 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -38,7 +38,7 @@ env: permissions: id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout + contents: read # This is required for actions/checkout packages: read # This is required for downloading from GitHub Package Registry jobs: @@ -49,6 +49,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -73,6 +78,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -111,6 +121,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -137,6 +152,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: From 64d0fe2efa0de5bdb94801ca577c46c8c30c84bb Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 8 Dec 2025 15:50:25 +0000 Subject: [PATCH 24/37] fix local npmrc --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e056149..1dee209f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -84,7 +84,7 @@ "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached", - "source=${localEnv:HOME}/.npmrc,target=/home/vscode/.aws,type=bind,consistency=cached" + "source=${localEnv:HOME}/.npmrc,target=/home/vscode/.npmrc,type=bind,consistency=cached" ], "name": "Devcontainer", "postCreateCommand": "scripts/devcontainer/postcreatecommand.sh" From ca58f3bd33eb2245a0a48e57c971d0a288a835ef Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 12 Dec 2025 15:24:54 +0000 Subject: [PATCH 25/37] Fix unit test timestamps, add source --- .../src/__test__/letter-repository.test.ts | 63 +++++++++++++------ internal/datastore/src/letter-repository.ts | 10 ++- internal/datastore/src/types.ts | 2 + .../mappers/__tests__/letter-mapper.test.ts | 40 +++++++----- .../__tests__/letter-operations.test.ts | 1 + .../handler/__tests__/upsert-handler.test.ts | 3 + .../src/handler/upsert-handler.ts | 3 +- .../helpers/create_letter_helpers.test.ts | 2 + .../src/helpers/create_letter_helpers.ts | 2 + tests/helpers/generate_fetch_testData.ts | 3 +- 10 files changed, 89 insertions(+), 40 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 3f277609..fe84d151 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -14,6 +14,7 @@ function createLetter( supplierId: string, letterId: string, status: Letter["status"] = "PENDING", + date: string = new Date().toISOString(), ): InsertLetter { return { id: letterId, @@ -22,8 +23,9 @@ function createLetter( groupId: "group1", url: `s3://bucket/${letterId}.pdf`, status, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, + source: "/data-plane/letter-rendering/pdf" }; } @@ -65,16 +67,31 @@ describe("LetterRepository", () => { expect(letter.status).toBe(status); } + function assertTtl(ttl: number, before: number, after: number) { + const expectedLower = Math.floor(before / 1000 + 60 * 60 * db.config.lettersTtlHours); + const expectedUpper = Math.floor(after / 1000 + 60 * 60 * db.config.lettersTtlHours); + expect(ttl).toBeGreaterThanOrEqual(expectedLower); + expect(ttl).toBeLessThanOrEqual(expectedUpper); + } + test("adds a letter to the database", async () => { const supplierId = "supplier1"; const letterId = "letter1"; + const date = new Date().toISOString(); - await letterRepository.putLetter(createLetter(supplierId, letterId)); + await letterRepository.putLetter(createLetter(supplierId, letterId, "PENDING", date)); const letter = await letterRepository.getLetterById(supplierId, letterId); expect(letter).toBeDefined(); expect(letter.id).toBe(letterId); expect(letter.supplierId).toBe(supplierId); + expect(letter.createdAt).toBe(date); + expect(letter.updatedAt).toBe(date); + expect(letter.supplierStatusSk).toBe(date); + expect(letter.supplierStatus).toBe("supplier1#PENDING"); + expect(letter.url).toBe("s3://bucket/letter1.pdf"); + expect(letter.specificationId).toBe("specification1"); + expect(letter.groupId).toBe("group1"); expect(letter.reasonCode).toBeUndefined(); expect(letter.reasonText).toBeUndefined(); }); @@ -312,7 +329,7 @@ describe("LetterRepository", () => { url: "s3://bucket/invalid-letter.pdf", status: "PENDING", supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, @@ -458,9 +475,7 @@ describe("LetterRepository", () => { }); test("successful upsert (update status) returns updated letter", async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2020, 0, 1)); - const letter: InsertLetter = createLetter("supplier1", "letter1"); + const letter: InsertLetter = createLetter("supplier1", "letter1", "PENDING", new Date(2020, 0, 1).toISOString()); const existingLetter: Letter = await letterRepository.putLetter(letter); const updateLetterStatus: UpsertLetter = { @@ -469,12 +484,16 @@ describe("LetterRepository", () => { status: "REJECTED", reasonCode: "R01", reasonText: "R01 text", + source: "/data-plane/letter-rendering/pdf", }; - jest.setSystemTime(new Date(2020, 0, 2)); + const before = Date.now(); + const result: Letter = await letterRepository.upsertLetter(updateLetterStatus); + const after = Date.now(); + expect(result).toEqual( expect.objectContaining({ id: "letter1", @@ -486,6 +505,7 @@ describe("LetterRepository", () => { supplierId: "supplier1", url: "s3://bucket/letter1.pdf", supplierStatus: "supplier1#REJECTED", + source: "/data-plane/letter-rendering/pdf", }), ); expect(Date.parse(result.updatedAt)).toBeGreaterThan( @@ -493,7 +513,7 @@ describe("LetterRepository", () => { ); expect(result.createdAt).toBe(existingLetter.createdAt); expect(result.createdAt).toBe(result.supplierStatusSk); - expect(result.ttl).toBeGreaterThan(existingLetter.ttl); + assertTtl(result.ttl, before, after); }); test("successful upsert (insert letter) returns created letter", async () => { @@ -504,14 +524,15 @@ describe("LetterRepository", () => { groupId: "group1", supplierId: "supplier1", url: "s3://bucket/letter1.pdf", + source: "/data-plane/letter-rendering/pdf", }; - const nowTest: Date = new Date(2020, 0, 1); - jest.useFakeTimers(); - jest.setSystemTime(nowTest); + const before = Date.now(); const result: Letter = await letterRepository.upsertLetter(insertLetter); + const after = Date.now(); + expect(result).toEqual( expect.objectContaining({ id: "letter1", @@ -523,25 +544,27 @@ describe("LetterRepository", () => { }), ); - expect(Date.parse(result.updatedAt)).toBe(nowTest.valueOf()); - expect(result.createdAt).toBe(result.updatedAt); + expect(Date.parse(result.createdAt)).toBeGreaterThanOrEqual(before); + expect(Date.parse(result.createdAt)).toBeLessThanOrEqual(after); + expect(result.updatedAt).toBe(result.createdAt); expect(result.supplierStatusSk).toBe(result.createdAt); - expect(result.ttl).toBe(new Date(2020, 0, 1, 1).valueOf() / 1000); + assertTtl(result.ttl, before, after); }); test("successful upsert without status change (update url)", async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2020, 0, 1)); - const insertLetter: InsertLetter = createLetter("supplier1", "letter1"); + const insertLetter: InsertLetter = createLetter("supplier1", "letter1", "PENDING", new Date(2020, 0, 1).toISOString()); const existingLetter = await letterRepository.putLetter(insertLetter); - jest.setSystemTime(new Date(2020, 0, 2)); + const before = Date.now(); + const result = await letterRepository.upsertLetter({ id: "letter1", supplierId: "supplier1", url: "s3://updateToPdf", }); + const after = Date.now(); + expect(result).toEqual( expect.objectContaining({ id: "letter1", @@ -558,7 +581,7 @@ describe("LetterRepository", () => { ); expect(result.createdAt).toBe(existingLetter.createdAt); expect(result.createdAt).toBe(result.supplierStatusSk); - expect(result.ttl).toBeGreaterThan(existingLetter.ttl); + assertTtl(result.ttl, before, after); }); test("unsuccessful upsert should throw error", async () => { diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index d76c1d1e..ea4fed8c 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -44,7 +44,7 @@ export class LetterRepository { const letterDb: Letter = { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: new Date().toISOString(), + supplierStatusSk: letter.createdAt, // needs to be an ISO timestamp ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), @@ -80,7 +80,7 @@ export class LetterRepository { lettersDb.push({ ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: Date.now().toString(), + supplierStatusSk: letter.createdAt, ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), @@ -318,6 +318,12 @@ export class LetterRepository { exprAttrValues[":reasonText"] = upsert.reasonText; } + if (upsert.source !== undefined) { + setParts.push("#source = :source"); + exprAttrNames["#source"] = "source"; + exprAttrValues[":source"] = upsert.source; + } + const updateExpression = `SET ${setParts.join(", ")}`; const command = new UpdateCommand({ diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 67ca6db3..e8a252fe 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -48,6 +48,7 @@ export const LetterSchema = LetterSchemaBase.extend({ supplierStatus: z.string().describe("Secondary index PK"), supplierStatusSk: z.string().describe("Secondary index SK"), ttl: z.int(), + source: z.string() }).describe("Letter"); /** @@ -79,6 +80,7 @@ export type UpsertLetter = { url?: string; reasonCode?: string; reasonText?: string; + source?: string; }; export const MISchemaBase = z.object({ diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index c19eb972..6f28ccf7 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -12,6 +12,7 @@ import { describe("letter-mapper", () => { it("maps an internal Letter to a PatchLetterResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", @@ -19,11 +20,12 @@ describe("letter-mapper", () => { specificationId: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, + source: "/data-plane/letter-rendering/pdf", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -42,6 +44,7 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a PatchLetterResponse with reasonCode and reasonText when present", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", @@ -49,13 +52,14 @@ describe("letter-mapper", () => { specificationId: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -76,6 +80,7 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a GetLetterResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", @@ -83,11 +88,12 @@ describe("letter-mapper", () => { specificationId: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, + source: "/data-plane/letter-rendering/pdf", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -106,6 +112,7 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a GetLetterResponse with reasonCode and reasonText when present", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", @@ -113,13 +120,14 @@ describe("letter-mapper", () => { specificationId: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -140,6 +148,7 @@ describe("letter-mapper", () => { }); it("maps an internal Letter collection to a GetLettersResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", @@ -147,13 +156,14 @@ describe("letter-mapper", () => { specificationId: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", }; const result: GetLettersResponse = mapToGetLettersResponse([ diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 9830640e..185e462d 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -38,6 +38,7 @@ function makeLetter(id: string, status: Letter["status"]): Letter { ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", }; } 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 f65fcca0..7de892f2 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -132,6 +132,7 @@ describe("createUpsertLetterHandler", () => { expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf"); expect(firstArg.status).toBe("PENDING"); expect(firstArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); const secondArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock .calls[1][0]; @@ -141,6 +142,8 @@ describe("createUpsertLetterHandler", () => { expect(secondArg.url).toBe("s3://letterDataBucket/letter2.pdf"); expect(secondArg.status).toBe("PENDING"); expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); }); test("invalid JSON body produces batch failure and logs error", async () => { diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 52dac948..37791694 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -24,8 +24,7 @@ function mapToUpsertLetter( upsertRequest.data.campaignId + upsertRequest.data.templateId, url: upsertRequest.data.url, - // TODO CCM-12997 source - // TODO CCM-12997 queueVisibility + source: upsertRequest.source, }; } diff --git a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts index a8b2a0db..b677fd5a 100644 --- a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts +++ b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts @@ -54,6 +54,7 @@ describe("Create letter helpers", () => { supplierId: "supplierId", updatedAt: "2020-02-01T00:00:00.000Z", url: "s3://bucketName/supplierId/targetFilename", + source: "/data-plane/letter-rendering/letter-test-data", }); }); @@ -81,6 +82,7 @@ describe("Create letter helpers", () => { status: "PENDING", createdAt: "2020-02-01T00:00:00.000Z", updatedAt: "2020-02-01T00:00:00.000Z", + source: "/data-plane/letter-rendering/letter-test-data", }); }); }); diff --git a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts index 062154ec..42798eca 100644 --- a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts +++ b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts @@ -42,6 +42,7 @@ export async function createLetter(params: { status: status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + source: "/data-plane/letter-rendering/letter-test-data", }; const letterRecord = await letterRepository.putLetter(letter); @@ -74,6 +75,7 @@ export function createLetterDto(params: { status: status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + source: "/data-plane/letter-rendering/letter-test-data", }; return letter; diff --git a/tests/helpers/generate_fetch_testData.ts b/tests/helpers/generate_fetch_testData.ts index 2fd63aa6..4182ebe1 100644 --- a/tests/helpers/generate_fetch_testData.ts +++ b/tests/helpers/generate_fetch_testData.ts @@ -19,7 +19,8 @@ export interface SupplierApiLetters { url: string, ttl: string, reasonText: string, - status: string + status: string, + source: string }; export async function createTestData(supplierId: string): Promise { From 8a74c033d6f292f6cf527660d2a98c2c8bee4fe1 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 12 Dec 2025 18:48:59 +0000 Subject: [PATCH 26/37] Add notification parsing logic --- .../terraform/components/api/README.md | 2 +- .../api/module_lambda_upsert_letter.tf | 6 +- lambdas/upsert-letter/package.json | 1 + .../handler/__tests__/upsert-handler.test.ts | 107 +++++++++++++++--- .../src/handler/upsert-handler.ts | 17 ++- package-lock.json | 1 + 6 files changed, 109 insertions(+), 25 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 60fb96f6..34e12f12 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -30,7 +30,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 | -| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplier_id = string, spec_id = string }))` |
{
"lv1": {
"spec_id": "spec1",
"supplier_id": "supplier1"
},
"lv2": {
"spec_id": "spec2",
"supplier_id": "supplier1"
},
"lv3": {
"spec_id": "spec3",
"supplier_id": "supplier2"
}
}
| no | +| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplier_id = string, spec_id = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 1899fc99..9c7ba3b2 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -43,9 +43,9 @@ module "upsert_letter" { variable "variant_map" { type = map(object({ supplier_id = string, spec_id = string })) default = { - "lv1" = { supplier_id = "supplier1", spec_id = "spec1" }, - "lv2" = { supplier_id = "supplier1", spec_id = "spec2" }, - "lv3" = { supplier_id = "supplier2", spec_id = "spec3" } + "lv1" = { supplierId = "supplier1", specId = "spec1" }, + "lv2" = { supplierId = "supplier1", specId = "spec2" }, + "lv3" = { supplierId = "supplier2", specId = "spec3" } } } diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index e5993ea7..b1583d58 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -5,6 +5,7 @@ "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", "@types/aws-lambda": "^8.10.148", + "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", "pino": "^9.7.0", "zod": "^4.1.11" 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 7de892f2..9acf2e9b 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -1,4 +1,4 @@ -import { SQSEvent } from "aws-lambda"; +import { SNSMessage, SQSEvent } from "aws-lambda"; import pino from "pino"; import { LetterRepository } from "internal/datastore/src"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; @@ -6,6 +6,25 @@ import createUpsertLetterHandler from "../upsert-handler"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; +function createNotification( + event: LetterRequestPreparedEvent, +): Partial { + return { + SignatureVersion: "", + Timestamp: "", + Signature: "", + SigningCertUrl: "", + MessageId: "", + Message: JSON.stringify(event), + MessageAttributes: {}, + Type: "Notification", + UnsubscribeUrl: "", + TopicArn: "", + Subject: "", + Token: "", + }; +} + function createValidEvent( overrides: Partial = {}, ): LetterRequestPreparedEvent { @@ -74,7 +93,7 @@ describe("createUpsertLetterHandler", () => { { messageId: "msg1", receiptHandle: "rh1", - body: JSON.stringify(createValidEvent()), + body: JSON.stringify(createNotification(createValidEvent())), attributes: { ApproximateReceiveCount: "", SentTimestamp: "", @@ -91,11 +110,13 @@ describe("createUpsertLetterHandler", () => { messageId: "msg2", receiptHandle: "rh2", body: JSON.stringify( - createValidEvent({ - id: "7b9a03ca-342a-4150-b56b-989109c45614", - domainId: "letter2", - url: "s3://letterDataBucket/letter2.pdf", - }), + createNotification( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45614", + domainId: "letter2", + url: "s3://letterDataBucket/letter2.pdf", + }), + ), ), attributes: { ApproximateReceiveCount: "", @@ -179,11 +200,13 @@ describe("createUpsertLetterHandler", () => { expect(result.batchItemFailures).toHaveLength(1); expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json"); - expect(mockedDeps.logger.error).toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert", + ); expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); }); - test("invalid schema produces batch failure and logs error", async () => { + test("invalid notification schema produces batch failure and logs error", async () => { const evt: SQSEvent = { Records: [ { @@ -216,7 +239,51 @@ describe("createUpsertLetterHandler", () => { expect(result.batchItemFailures).toHaveLength(1); expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); - expect(mockedDeps.logger.error).toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert", + ); + expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); + }); + + test("invalid event schema produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + { + messageId: "bad-schema", + receiptHandle: "rh1", + body: JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ bad: "shape" }), + }), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }, + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); + + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error parsing letter event in upsert", + ); expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); }); @@ -231,10 +298,12 @@ describe("createUpsertLetterHandler", () => { messageId: "ok-msg", receiptHandle: "rh1", body: JSON.stringify( - createValidEvent({ - id: "7b9a03ca-342a-4150-b56b-989109c45615", - data: { domainId: "ok" }, - }), + createNotification( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45615", + data: { domainId: "ok" }, + }), + ), ), attributes: { ApproximateReceiveCount: "", @@ -252,10 +321,12 @@ describe("createUpsertLetterHandler", () => { messageId: "fail-msg", receiptHandle: "rh2", body: JSON.stringify( - createValidEvent({ - id: "7b9a03ca-342a-4150-b56b-989109c45616", - data: { domainId: "ok" }, - }), + createNotification( + createValidEvent({ + id: "7b9a03ca-342a-4150-b56b-989109c45616", + data: { domainId: "ok" }, + }), + ), ), attributes: { ApproximateReceiveCount: "", diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 37791694..eeb67f45 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -1,4 +1,4 @@ -import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; +import { SQSBatchItemFailure, SQSEvent, SQSHandler, SNSMessage } from "aws-lambda"; import { UpsertLetter } from "@internal/datastore"; import { $LetterRequestPreparedEvent, @@ -6,6 +6,7 @@ import { } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { ZodError } from "zod"; import { Deps } from "../config/deps"; +import { no } from "zod/v4/locales"; type SupplierSpec = { supplierId: string; specId: string }; @@ -41,8 +42,18 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { const tasks = event.Records.map(async (record) => { try { + const notification = JSON.parse(record.body) as Partial; + if ( + notification.Type !== "Notification" || + typeof notification.Message !== "string" + ) { + throw new Error( + "SQS record does not contain SNS Notification with string Message", + ); + } + const upsertRequest: LetterRequestPreparedEvent = - $LetterRequestPreparedEvent.parse(JSON.parse(record.body)); + $LetterRequestPreparedEvent.parse(JSON.parse(notification.Message)); const supplierSpec: SupplierSpec = resolveSupplierForVariant( upsertRequest.data.letterVariantId, @@ -58,7 +69,7 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { } catch (error) { if (error instanceof ZodError) { deps.logger.error( - { issues: error.issues }, + { issues: error.issues, body: record.body }, "Error parsing letter event in upsert", ); } diff --git a/package-lock.json b/package-lock.json index 79febc0e..bb7ba3ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3023,6 +3023,7 @@ "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", "@types/aws-lambda": "^8.10.148", + "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", "pino": "^9.7.0", "zod": "^4.1.11" From cb2972923074607ea56b537ee78ea532d842aedc Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 12 Dec 2025 21:39:22 +0000 Subject: [PATCH 27/37] fix tf var --- infrastructure/terraform/components/api/README.md | 2 +- .../terraform/components/api/module_lambda_upsert_letter.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 34e12f12..35e9c26a 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -30,7 +30,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 | -| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplier_id = string, spec_id = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | +| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 9c7ba3b2..fa191a87 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -41,7 +41,7 @@ module "upsert_letter" { } variable "variant_map" { - type = map(object({ supplier_id = string, spec_id = string })) + type = map(object({ supplierId = string, specId = string })) default = { "lv1" = { supplierId = "supplier1", specId = "spec1" }, "lv2" = { supplierId = "supplier1", specId = "spec2" }, From 27152851431b69b0857ba8db9b74c6e6c76387f2 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 12 Dec 2025 22:21:17 +0000 Subject: [PATCH 28/37] Fix ddb permissions --- .../terraform/components/api/module_lambda_upsert_letter.tf | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index fa191a87..38c3ba2f 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -69,7 +69,10 @@ data "aws_iam_policy_document" "upsert_letter_lambda" { effect = "Allow" actions = [ - "dynamodb:PutItem" + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:UpdateItem" ] resources = [ From d6e80aacbc60f43ea810a496d390a5969097fdc5 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 15 Dec 2025 13:51:23 +0000 Subject: [PATCH 29/37] Add latest version event schema letter rendering --- lambdas/upsert-letter/package.json | 2 +- .../handler/__tests__/upsert-handler.test.ts | 12 +++++------ .../src/handler/upsert-handler.ts | 18 ++++++++++------- package-lock.json | 20 +++++++++---------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index b1583d58..756e871a 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -3,7 +3,7 @@ "@aws-sdk/client-dynamodb": "^3.858.0", "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", - "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", 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 9acf2e9b..94249c30 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -1,13 +1,13 @@ import { SNSMessage, SQSEvent } from "aws-lambda"; import pino from "pino"; import { LetterRepository } from "internal/datastore/src"; -import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import createUpsertLetterHandler from "../upsert-handler"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; function createNotification( - event: LetterRequestPreparedEvent, + event: LetterRequestPreparedEventV2, ): Partial { return { SignatureVersion: "", @@ -27,7 +27,7 @@ function createNotification( function createValidEvent( overrides: Partial = {}, -): LetterRequestPreparedEvent { +): LetterRequestPreparedEventV2 { // minimal valid event matching the prepared letter schema const now = new Date().toISOString(); @@ -36,11 +36,11 @@ function createValidEvent( id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", source: "/data-plane/letter-rendering/test", subject: "client/client1/letter-request/letterRequest1", - type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2", time: now, dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", - dataschemaversion: "1.0.0", + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.0.schema.json", + dataschemaversion: "2.0.0", data: { domainId: overrides.domainId ?? "letter1", letterVariantId: overrides.letterVariantId ?? "lv1", diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index eeb67f45..9bb889d8 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -1,17 +1,21 @@ -import { SQSBatchItemFailure, SQSEvent, SQSHandler, SNSMessage } from "aws-lambda"; +import { + SNSMessage, + SQSBatchItemFailure, + SQSEvent, + SQSHandler, +} from "aws-lambda"; import { UpsertLetter } from "@internal/datastore"; import { - $LetterRequestPreparedEvent, - LetterRequestPreparedEvent, + $LetterRequestPreparedEventV2, + LetterRequestPreparedEventV2, } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { ZodError } from "zod"; import { Deps } from "../config/deps"; -import { no } from "zod/v4/locales"; type SupplierSpec = { supplierId: string; specId: string }; function mapToUpsertLetter( - upsertRequest: LetterRequestPreparedEvent, + upsertRequest: LetterRequestPreparedEventV2, supplier: string, spec: string, ): UpsertLetter { @@ -52,8 +56,8 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { ); } - const upsertRequest: LetterRequestPreparedEvent = - $LetterRequestPreparedEvent.parse(JSON.parse(notification.Message)); + const upsertRequest: LetterRequestPreparedEventV2 = + $LetterRequestPreparedEventV2.parse(JSON.parse(notification.Message)); const supplierSpec: SupplierSpec = resolveSupplierForVariant( upsertRequest.data.letterVariantId, diff --git a/package-lock.json b/package-lock.json index bb7ba3ec..4146f9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3021,7 +3021,7 @@ "@aws-sdk/client-dynamodb": "^3.858.0", "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", - "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", @@ -3436,6 +3436,15 @@ "node": ">=18" } }, + "lambdas/upsert-letter/node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering": { + "version": "2.0.0", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/2.0.0/329230fced77de6141adb0fb991023d7f8ae10b7", + "integrity": "sha512-rTkq199jGm8+fMoB/3uvYOOiVCgYn9vN4LBBdCVB7cTnskMkI+5NJEjpxkvO/jqUr8a0JkZY8cRTN8xZ1nNaKQ==", + "license": "MIT", + "dependencies": { + "zod": "^4.0.17" + } + }, "lambdas/upsert-letter/node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -9297,15 +9306,6 @@ "node": ">=8.6.0" } }, - "node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering": { - "version": "1.1.5", - "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/1.1.5/7949805ee5a21ff934f798e73822774c64366677", - "integrity": "sha512-9Rx3dkYdVqwBbeE3YpJkdiu54fHyHFfeLFZGNRUuxX/dsi0obnJDxdPfBk07aK9jopDZmuVvKQS1O6Sigl+SuQ==", - "dependencies": { - "@asyncapi/bundler": "^0.6.4", - "zod": "^4.1.11" - } - }, "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { "resolved": "internal/events", "link": true From 595d4b6c9a684b60510d232b1fb54253b8a93e57 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 15 Dec 2025 14:55:04 +0000 Subject: [PATCH 30/37] Add trying v1 parse if v2 fails --- lambdas/upsert-letter/package.json | 1 + .../handler/__tests__/upsert-handler.test.ts | 267 ++++++++++-------- .../src/handler/upsert-handler.ts | 38 ++- package-lock.json | 11 + 4 files changed, 194 insertions(+), 123 deletions(-) diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index 756e871a..a808deea 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -4,6 +4,7 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", 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 94249c30..7ad45c4a 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -2,12 +2,31 @@ import { SNSMessage, SQSEvent } from "aws-lambda"; import pino from "pino"; import { LetterRepository } from "internal/datastore/src"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import createUpsertLetterHandler from "../upsert-handler"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; +function createSqsRecord(msgId: string, body: string) { + return { + messageId: msgId, + receiptHandle: "", + body, + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }; +} function createNotification( - event: LetterRequestPreparedEventV2, + event: LetterRequestPreparedEventV2 | LetterRequestPreparedEvent, ): Partial { return { SignatureVersion: "", @@ -25,7 +44,7 @@ function createNotification( }; } -function createValidEvent( +function createValidV2Event( overrides: Partial = {}, ): LetterRequestPreparedEventV2 { // minimal valid event matching the prepared letter schema @@ -65,12 +84,52 @@ function createValidEvent( }; } +function createValidV1Event( + overrides: Partial = {}, +): LetterRequestPreparedEvent { + // minimal valid event matching the prepared letter schema + const now = new Date().toISOString(); + + return { + specversion: "1.0", + id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", + source: "/data-plane/letter-rendering/test", + subject: "client/client1/letter-request/letterRequest1", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", + time: now, + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", + dataschemaversion: "1.0.0", + data: { + domainId: overrides.domainId ?? "letter1", + letterVariantId: overrides.letterVariantId ?? "lv1", + requestId: overrides.requestId ?? "request1", + requestItemId: overrides.requestItemId ?? "requestItem1", + requestItemPlanId: overrides.requestItemPlanId ?? "requestItemPlan1", + clientId: overrides.clientId ?? "client1", + campaignId: overrides.campaignId ?? "campaign1", + templateId: overrides.templateId ?? "template1", + url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", + sha256Hash: + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", + createdAt: now, + pageCount: 1, + status: "PREPARED", + }, + traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", + recordedtime: now, + severitynumber: 2, + severitytext: "INFO", + plane: "data", + }; +} + describe("createUpsertLetterHandler", () => { const mockedDeps: jest.Mocked = { letterRepo: { upsertLetter: jest.fn(), } as unknown as LetterRepository, - logger: { error: jest.fn() } as unknown as pino.Logger, + logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, env: { LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME", LETTER_TTL_HOURS: 12_960, @@ -90,46 +149,22 @@ describe("createUpsertLetterHandler", () => { test("processes all records successfully and returns no batch failures", async () => { const evt: SQSEvent = { Records: [ - { - messageId: "msg1", - receiptHandle: "rh1", - body: JSON.stringify(createNotification(createValidEvent())), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, - { - messageId: "msg2", - receiptHandle: "rh2", - body: JSON.stringify( + createSqsRecord( + "msg1", + JSON.stringify(createNotification(createValidV2Event())), + ), + createSqsRecord( + "msg2", + JSON.stringify( createNotification( - createValidEvent({ + createValidV2Event({ id: "7b9a03ca-342a-4150-b56b-989109c45614", domainId: "letter2", url: "s3://letterDataBucket/letter2.pdf", }), ), ), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, + ), ], }; @@ -167,25 +202,25 @@ describe("createUpsertLetterHandler", () => { expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); }); - test("invalid JSON body produces batch failure and logs error", async () => { + test("processes all v1 records successfully and returns no batch failures", async () => { const evt: SQSEvent = { Records: [ - { - messageId: "bad-json", - receiptHandle: "rh1", - body: "this-is-not-json", - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, + createSqsRecord( + "msg1", + JSON.stringify(createNotification(createValidV1Event())), + ), + createSqsRecord( + "msg2", + JSON.stringify( + createNotification( + createValidV1Event({ + id: "7b9a03ca-342a-4150-b56b-989109c45614", + domainId: "letter2", + url: "s3://letterDataBucket/letter2.pdf", + }), + ), + ), + ), ], }; @@ -195,6 +230,45 @@ describe("createUpsertLetterHandler", () => { {} as any, ); + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(0); + + expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + + const firstArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + .calls[0][0]; + expect(firstArg.id).toBe("letter1"); + expect(firstArg.supplierId).toBe("supplier1"); + expect(firstArg.specificationId).toBe("spec1"); + expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf"); + expect(firstArg.status).toBe("PENDING"); + expect(firstArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + + const secondArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + .calls[1][0]; + expect(secondArg.id).toBe("letter2"); + expect(secondArg.supplierId).toBe("supplier1"); + expect(secondArg.specificationId).toBe("spec1"); + expect(secondArg.url).toBe("s3://letterDataBucket/letter2.pdf"); + expect(secondArg.status).toBe("PENDING"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + }); + + test("invalid JSON body produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [createSqsRecord("bad-json", "this-is-not-json")], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + expect(result).toBeDefined(); if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(1); @@ -209,22 +283,10 @@ describe("createUpsertLetterHandler", () => { test("invalid notification schema produces batch failure and logs error", async () => { const evt: SQSEvent = { Records: [ - { - messageId: "bad-schema", - receiptHandle: "rh1", - body: JSON.stringify({ not: "the expected shape" }), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, + createSqsRecord( + "bad-schema", + JSON.stringify({ not: "the expected shape" }), + ), ], }; @@ -248,25 +310,13 @@ describe("createUpsertLetterHandler", () => { test("invalid event schema produces batch failure and logs error", async () => { const evt: SQSEvent = { Records: [ - { - messageId: "bad-schema", - receiptHandle: "rh1", - body: JSON.stringify({ + createSqsRecord( + "bad-schema", + JSON.stringify({ Type: "Notification", Message: JSON.stringify({ bad: "shape" }), }), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, + ), ], }; @@ -281,6 +331,9 @@ describe("createUpsertLetterHandler", () => { expect(result.batchItemFailures).toHaveLength(1); expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); + expect((mockedDeps.logger.info as jest.Mock).mock.calls[0][1]).toBe( + "Trying to parse message with V1 schema", + ); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( "Error parsing letter event in upsert", ); @@ -294,52 +347,28 @@ describe("createUpsertLetterHandler", () => { const evt: SQSEvent = { Records: [ - { - messageId: "ok-msg", - receiptHandle: "rh1", - body: JSON.stringify( + createSqsRecord( + "ok-msg", + JSON.stringify( createNotification( - createValidEvent({ + createValidV2Event({ id: "7b9a03ca-342a-4150-b56b-989109c45615", data: { domainId: "ok" }, }), ), ), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, - { - messageId: "fail-msg", - receiptHandle: "rh2", - body: JSON.stringify( + ), + createSqsRecord( + "fail-msg", + JSON.stringify( createNotification( - createValidEvent({ + createValidV2Event({ id: "7b9a03ca-342a-4150-b56b-989109c45616", data: { domainId: "ok" }, }), ), ), - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", - }, + ), ], }; diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 9bb889d8..52056aa3 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -5,6 +5,10 @@ import { SQSHandler, } from "aws-lambda"; import { UpsertLetter } from "@internal/datastore"; +import { + $LetterRequestPreparedEvent, + LetterRequestPreparedEvent, +} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { $LetterRequestPreparedEventV2, LetterRequestPreparedEventV2, @@ -15,7 +19,7 @@ import { Deps } from "../config/deps"; type SupplierSpec = { supplierId: string; specId: string }; function mapToUpsertLetter( - upsertRequest: LetterRequestPreparedEventV2, + upsertRequest: LetterRequestPreparedEventV2 | LetterRequestPreparedEvent, supplier: string, spec: string, ): UpsertLetter { @@ -40,6 +44,27 @@ function resolveSupplierForVariant( return deps.env.VARIANT_MAP[variantId]; } +function parseLetterRequestPreparedEvent( + message: string, + deps: Deps, +): LetterRequestPreparedEvent | LetterRequestPreparedEventV2 { + const parsedMessage = JSON.parse(message); + + try { + const upsertRequest: LetterRequestPreparedEventV2 = + $LetterRequestPreparedEventV2.parse(parsedMessage); + return upsertRequest; + } catch (error) { + deps.logger.info( + { err: error, message }, + "Trying to parse message with V1 schema", + ); + const upsertRequest: LetterRequestPreparedEvent = + $LetterRequestPreparedEvent.parse(parsedMessage); + return upsertRequest; + } +} + export default function createUpsertLetterHandler(deps: Deps): SQSHandler { return async (event: SQSEvent) => { const batchItemFailures: SQSBatchItemFailure[] = []; @@ -56,8 +81,12 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { ); } - const upsertRequest: LetterRequestPreparedEventV2 = - $LetterRequestPreparedEventV2.parse(JSON.parse(notification.Message)); + const upsertRequest: + | LetterRequestPreparedEvent + | LetterRequestPreparedEventV2 = parseLetterRequestPreparedEvent( + notification.Message, + deps, + ); const supplierSpec: SupplierSpec = resolveSupplierForVariant( upsertRequest.data.letterVariantId, @@ -76,8 +105,9 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { { issues: error.issues, body: record.body }, "Error parsing letter event in upsert", ); + } else { + deps.logger.error({ err: error }, "Error processing upsert"); } - deps.logger.error({ err: error }, "Error processing upsert"); batchItemFailures.push({ itemIdentifier: record.messageId }); } }); diff --git a/package-lock.json b/package-lock.json index 4146f9a8..7a57869b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3022,6 +3022,7 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", @@ -9306,6 +9307,16 @@ "node": ">=8.6.0" } }, + "node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": { + "name": "@nhsdigital/nhs-notify-event-schemas-letter-rendering", + "version": "1.1.5", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/1.1.5/7949805ee5a21ff934f798e73822774c64366677", + "integrity": "sha512-9Rx3dkYdVqwBbeE3YpJkdiu54fHyHFfeLFZGNRUuxX/dsi0obnJDxdPfBk07aK9jopDZmuVvKQS1O6Sigl+SuQ==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.11" + } + }, "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { "resolved": "internal/events", "link": true From 78ece30fe1607c85c5f8d7b95371301969ceb547 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 16 Dec 2025 12:50:53 +0000 Subject: [PATCH 31/37] Add subject --- .../src/__test__/letter-repository.test.ts | 40 ++++++++++++++++--- internal/datastore/src/letter-repository.ts | 5 +++ internal/datastore/src/types.ts | 4 +- .../src/handler/upsert-handler.ts | 1 + 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index fe84d151..96eee895 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -25,7 +25,8 @@ function createLetter( status, createdAt: date, updatedAt: date, - source: "/data-plane/letter-rendering/pdf" + source: "/data-plane/letter-rendering/pdf", + subject: `client/1/letter-request/${letterId}`, }; } @@ -68,8 +69,12 @@ describe("LetterRepository", () => { } function assertTtl(ttl: number, before: number, after: number) { - const expectedLower = Math.floor(before / 1000 + 60 * 60 * db.config.lettersTtlHours); - const expectedUpper = Math.floor(after / 1000 + 60 * 60 * db.config.lettersTtlHours); + const expectedLower = Math.floor( + before / 1000 + 60 * 60 * db.config.lettersTtlHours, + ); + const expectedUpper = Math.floor( + after / 1000 + 60 * 60 * db.config.lettersTtlHours, + ); expect(ttl).toBeGreaterThanOrEqual(expectedLower); expect(ttl).toBeLessThanOrEqual(expectedUpper); } @@ -79,7 +84,9 @@ describe("LetterRepository", () => { const letterId = "letter1"; const date = new Date().toISOString(); - await letterRepository.putLetter(createLetter(supplierId, letterId, "PENDING", date)); + await letterRepository.putLetter( + createLetter(supplierId, letterId, "PENDING", date), + ); const letter = await letterRepository.getLetterById(supplierId, letterId); expect(letter).toBeDefined(); @@ -94,6 +101,7 @@ describe("LetterRepository", () => { expect(letter.groupId).toBe("group1"); expect(letter.reasonCode).toBeUndefined(); expect(letter.reasonText).toBeUndefined(); + expect(letter.subject).toBe(`client/1/letter-request/${letterId}`); }); test("fetches a letter by id", async () => { @@ -106,6 +114,9 @@ describe("LetterRepository", () => { specificationId: "specification1", groupId: "group1", status: "PENDING", + url: "s3://bucket/letter1.pdf", + source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }), ); }); @@ -475,7 +486,12 @@ describe("LetterRepository", () => { }); test("successful upsert (update status) returns updated letter", async () => { - const letter: InsertLetter = createLetter("supplier1", "letter1", "PENDING", new Date(2020, 0, 1).toISOString()); + const letter: InsertLetter = createLetter( + "supplier1", + "letter1", + "PENDING", + new Date(2020, 0, 1).toISOString(), + ); const existingLetter: Letter = await letterRepository.putLetter(letter); const updateLetterStatus: UpsertLetter = { @@ -485,6 +501,7 @@ describe("LetterRepository", () => { reasonCode: "R01", reasonText: "R01 text", source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }; const before = Date.now(); @@ -506,6 +523,7 @@ describe("LetterRepository", () => { url: "s3://bucket/letter1.pdf", supplierStatus: "supplier1#REJECTED", source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }), ); expect(Date.parse(result.updatedAt)).toBeGreaterThan( @@ -525,6 +543,7 @@ describe("LetterRepository", () => { supplierId: "supplier1", url: "s3://bucket/letter1.pdf", source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }; const before = Date.now(); @@ -541,6 +560,8 @@ describe("LetterRepository", () => { groupId: "group1", supplierId: "supplier1", url: "s3://bucket/letter1.pdf", + source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }), ); @@ -552,7 +573,12 @@ describe("LetterRepository", () => { }); test("successful upsert without status change (update url)", async () => { - const insertLetter: InsertLetter = createLetter("supplier1", "letter1", "PENDING", new Date(2020, 0, 1).toISOString()); + const insertLetter: InsertLetter = createLetter( + "supplier1", + "letter1", + "PENDING", + new Date(2020, 0, 1).toISOString(), + ); const existingLetter = await letterRepository.putLetter(insertLetter); const before = Date.now(); @@ -574,6 +600,8 @@ describe("LetterRepository", () => { supplierId: "supplier1", url: "s3://updateToPdf", supplierStatus: "supplier1#PENDING", + source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }), ); expect(Date.parse(result.updatedAt)).toBeGreaterThan( diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index ea4fed8c..2d7c4ca5 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -324,6 +324,11 @@ export class LetterRepository { exprAttrValues[":source"] = upsert.source; } + if (upsert.subject !== undefined) { + setParts.push("subject = :subject"); + exprAttrValues[":subject"] = upsert.subject; + } + const updateExpression = `SET ${setParts.join(", ")}`; const command = new UpdateCommand({ diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index e8a252fe..d28a3912 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -48,7 +48,8 @@ export const LetterSchema = LetterSchemaBase.extend({ supplierStatus: z.string().describe("Secondary index PK"), supplierStatusSk: z.string().describe("Secondary index SK"), ttl: z.int(), - source: z.string() + source: z.string(), + subject: z.string(), }).describe("Letter"); /** @@ -81,6 +82,7 @@ export type UpsertLetter = { reasonCode?: string; reasonText?: string; source?: string; + subject?: string; }; export const MISchemaBase = z.object({ diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 52056aa3..a07bc7f7 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -34,6 +34,7 @@ function mapToUpsertLetter( upsertRequest.data.templateId, url: upsertRequest.data.url, source: upsertRequest.source, + subject: upsertRequest.subject, }; } From a8fa07e8f73aea21bf5daad784c6a5e1967c18ff Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 18 Dec 2025 13:04:05 +0000 Subject: [PATCH 32/37] Split upsert operations --- .../src/__test__/letter-repository.test.ts | 147 +--------- internal/datastore/src/letter-repository.ts | 100 ------- internal/datastore/src/types.ts | 13 - lambdas/upsert-letter/package.json | 1 + .../handler/__tests__/upsert-handler.test.ts | 257 +++++++++++------- .../src/handler/upsert-handler.ts | 164 +++++++---- package-lock.json | 1 + 7 files changed, 276 insertions(+), 407 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 96eee895..7c46ff96 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -7,7 +7,7 @@ import { setupDynamoDBContainer, } from "./db"; import { LetterRepository } from "../letter-repository"; -import { InsertLetter, Letter, UpdateLetter, UpsertLetter } from "../types"; +import { InsertLetter, Letter, UpdateLetter } from "../types"; import { LogStream, createTestLogger } from "./logs"; function createLetter( @@ -484,149 +484,4 @@ describe("LetterRepository", () => { ]), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); - - test("successful upsert (update status) returns updated letter", async () => { - const letter: InsertLetter = createLetter( - "supplier1", - "letter1", - "PENDING", - new Date(2020, 0, 1).toISOString(), - ); - const existingLetter: Letter = await letterRepository.putLetter(letter); - - const updateLetterStatus: UpsertLetter = { - id: "letter1", - supplierId: "supplier1", - status: "REJECTED", - reasonCode: "R01", - reasonText: "R01 text", - source: "/data-plane/letter-rendering/pdf", - subject: "client/1/letter-request/letter1", - }; - - const before = Date.now(); - - const result: Letter = - await letterRepository.upsertLetter(updateLetterStatus); - - const after = Date.now(); - - expect(result).toEqual( - expect.objectContaining({ - id: "letter1", - status: "REJECTED", - specificationId: "specification1", - groupId: "group1", - reasonCode: "R01", - reasonText: "R01 text", - supplierId: "supplier1", - url: "s3://bucket/letter1.pdf", - supplierStatus: "supplier1#REJECTED", - source: "/data-plane/letter-rendering/pdf", - subject: "client/1/letter-request/letter1", - }), - ); - expect(Date.parse(result.updatedAt)).toBeGreaterThan( - Date.parse(existingLetter.updatedAt), - ); - expect(result.createdAt).toBe(existingLetter.createdAt); - expect(result.createdAt).toBe(result.supplierStatusSk); - assertTtl(result.ttl, before, after); - }); - - test("successful upsert (insert letter) returns created letter", async () => { - const insertLetter: UpsertLetter = { - id: "letter1", - status: "PENDING", - specificationId: "specification1", - groupId: "group1", - supplierId: "supplier1", - url: "s3://bucket/letter1.pdf", - source: "/data-plane/letter-rendering/pdf", - subject: "client/1/letter-request/letter1", - }; - - const before = Date.now(); - - const result: Letter = await letterRepository.upsertLetter(insertLetter); - - const after = Date.now(); - - expect(result).toEqual( - expect.objectContaining({ - id: "letter1", - status: "PENDING", - specificationId: "specification1", - groupId: "group1", - supplierId: "supplier1", - url: "s3://bucket/letter1.pdf", - source: "/data-plane/letter-rendering/pdf", - subject: "client/1/letter-request/letter1", - }), - ); - - expect(Date.parse(result.createdAt)).toBeGreaterThanOrEqual(before); - expect(Date.parse(result.createdAt)).toBeLessThanOrEqual(after); - expect(result.updatedAt).toBe(result.createdAt); - expect(result.supplierStatusSk).toBe(result.createdAt); - assertTtl(result.ttl, before, after); - }); - - test("successful upsert without status change (update url)", async () => { - const insertLetter: InsertLetter = createLetter( - "supplier1", - "letter1", - "PENDING", - new Date(2020, 0, 1).toISOString(), - ); - const existingLetter = await letterRepository.putLetter(insertLetter); - - const before = Date.now(); - - const result = await letterRepository.upsertLetter({ - id: "letter1", - supplierId: "supplier1", - url: "s3://updateToPdf", - }); - - const after = Date.now(); - - expect(result).toEqual( - expect.objectContaining({ - id: "letter1", - status: "PENDING", - specificationId: "specification1", - groupId: "group1", - supplierId: "supplier1", - url: "s3://updateToPdf", - supplierStatus: "supplier1#PENDING", - source: "/data-plane/letter-rendering/pdf", - subject: "client/1/letter-request/letter1", - }), - ); - expect(Date.parse(result.updatedAt)).toBeGreaterThan( - Date.parse(existingLetter.updatedAt), - ); - expect(result.createdAt).toBe(existingLetter.createdAt); - expect(result.createdAt).toBe(result.supplierStatusSk); - assertTtl(result.ttl, before, after); - }); - - test("unsuccessful upsert should throw error", async () => { - const mockSend = jest.fn().mockResolvedValue({ Items: null }); - const mockDdbClient = { send: mockSend } as any; - const repo = new LetterRepository( - mockDdbClient, - { debug: jest.fn() } as any, - { lettersTableName: "letters", lettersTtlHours: 1 }, - ); - - await expect( - repo.upsertLetter({ - id: "letter1", - status: "PENDING", - supplierId: "supplier1", - }), - ).rejects.toThrow("upsertLetter: no attributes returned"); - }); }); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 2d7c4ca5..66796b5e 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -16,7 +16,6 @@ import { LetterSchema, LetterSchemaBase, UpdateLetter, - UpsertLetter, } from "./types"; export type PagingOptions = Partial<{ @@ -250,103 +249,4 @@ export class LetterRepository { ); return z.array(LetterSchemaBase).parse(result.Items ?? []); } - - async upsertLetter(upsert: UpsertLetter): Promise { - const now = new Date(); - const ttl = Math.floor( - now.valueOf() / 1000 + 60 * 60 * this.config.lettersTtlHours, - ); - - const setParts: string[] = []; - const exprAttrNames: Record = {}; - const exprAttrValues: Record = {}; - - // updateAt is always updated - setParts.push("updatedAt = :updatedAt"); - exprAttrValues[":updatedAt"] = now.toISOString(); - - // ttl is always updated - setParts.push("#ttl = :ttl"); - exprAttrNames["#ttl"] = "ttl"; - exprAttrValues[":ttl"] = ttl; - - // createdAt only if first time - setParts.push("createdAt = if_not_exists(createdAt, :createdAt)"); - exprAttrValues[":createdAt"] = now.toISOString(); - - // status and related supplierStatus if provided - if (upsert.status !== undefined) { - exprAttrNames["#status"] = "status"; - setParts.push("#status = :status"); - exprAttrValues[":status"] = upsert.status; - - setParts.push("supplierStatus = :supplierStatus"); - exprAttrValues[":supplierStatus"] = - `${upsert.supplierId}#${upsert.status}`; - - // supplierStatusSk should replicate createdAt - setParts.push( - "supplierStatusSk = if_not_exists(supplierStatusSk, :supplierStatusSk)", - ); - exprAttrValues[":supplierStatusSk"] = now.toISOString(); - } - - // fields that could be updated - - if (upsert.specificationId !== undefined) { - setParts.push("specificationId = :specificationId"); - exprAttrValues[":specificationId"] = upsert.specificationId; - } - - if (upsert.url !== undefined) { - setParts.push("#url = :url"); - exprAttrNames["#url"] = "url"; - exprAttrValues[":url"] = upsert.url; - } - - if (upsert.groupId !== undefined) { - setParts.push("groupId = :groupId"); - exprAttrValues[":groupId"] = upsert.groupId; - } - - if (upsert.reasonCode !== undefined) { - setParts.push("reasonCode = :reasonCode"); - exprAttrValues[":reasonCode"] = upsert.reasonCode; - } - if (upsert.reasonText !== undefined) { - setParts.push("reasonText = :reasonText"); - exprAttrValues[":reasonText"] = upsert.reasonText; - } - - if (upsert.source !== undefined) { - setParts.push("#source = :source"); - exprAttrNames["#source"] = "source"; - exprAttrValues[":source"] = upsert.source; - } - - if (upsert.subject !== undefined) { - setParts.push("subject = :subject"); - exprAttrValues[":subject"] = upsert.subject; - } - - const updateExpression = `SET ${setParts.join(", ")}`; - - const command = new UpdateCommand({ - TableName: this.config.lettersTableName, - Key: { supplierId: upsert.supplierId, id: upsert.id }, - UpdateExpression: updateExpression, - ExpressionAttributeNames: exprAttrNames, - ExpressionAttributeValues: exprAttrValues, - ReturnValues: "ALL_NEW", - }); - - const result = await this.ddbClient.send(command); - - if (!result.Attributes) { - throw new Error("upsertLetter: no attributes returned"); - } - - this.log.debug({ exprAttrValues }, `Upsert to letter=${upsert.id}`); - return LetterSchema.parse(result.Attributes); - } } diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index d28a3912..a77e8e66 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -71,19 +71,6 @@ export type UpdateLetter = { reasonCode?: string; reasonText?: string; }; -export type UpsertLetter = { - id: string; - supplierId: string; - // fields that might set/overwrite - status?: Letter["status"]; - specificationId?: string; - groupId?: string; - url?: string; - reasonCode?: string; - reasonText?: string; - source?: string; - subject?: string; -}; export const MISchemaBase = z.object({ id: z.string(), diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index a808deea..4ccd4d05 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -5,6 +5,7 @@ "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.5", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", 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 7ad45c4a..78197a0a 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -3,6 +3,10 @@ import pino from "pino"; import { LetterRepository } from "internal/datastore/src"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; import createUpsertLetterHandler from "../upsert-handler"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; @@ -25,8 +29,12 @@ function createSqsRecord(msgId: string, body: string) { awsRegion: "", }; } + function createNotification( - event: LetterRequestPreparedEventV2 | LetterRequestPreparedEvent, + event: + | LetterRequestPreparedEventV2 + | LetterRequestPreparedEvent + | LetterEvent, ): Partial { return { SignatureVersion: "", @@ -44,10 +52,9 @@ function createNotification( }; } -function createValidV2Event( +function createPreparedV1Event( overrides: Partial = {}, -): LetterRequestPreparedEventV2 { - // minimal valid event matching the prepared letter schema +): LetterRequestPreparedEvent { const now = new Date().toISOString(); return { @@ -55,20 +62,20 @@ function createValidV2Event( id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", source: "/data-plane/letter-rendering/test", subject: "client/client1/letter-request/letterRequest1", - type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", time: now, dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.0.schema.json", - dataschemaversion: "2.0.0", + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", + dataschemaversion: "1.0.0", data: { domainId: overrides.domainId ?? "letter1", - letterVariantId: overrides.letterVariantId ?? "lv1", - requestId: overrides.requestId ?? "request1", - requestItemId: overrides.requestItemId ?? "requestItem1", - requestItemPlanId: overrides.requestItemPlanId ?? "requestItemPlan1", - clientId: overrides.clientId ?? "client1", - campaignId: overrides.campaignId ?? "campaign1", - templateId: overrides.templateId ?? "template1", + letterVariantId: "lv1", + requestId: "request1", + requestItemId: "requestItem1", + requestItemPlanId: "requestItemPlan1", + clientId: "client1", + campaignId: "campaign1", + templateId: "template1", url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", sha256Hash: "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", @@ -84,50 +91,64 @@ function createValidV2Event( }; } -function createValidV1Event( +function createPreparedV2Event( overrides: Partial = {}, -): LetterRequestPreparedEvent { - // minimal valid event matching the prepared letter schema - const now = new Date().toISOString(); - +): LetterRequestPreparedEventV2 { return { - specversion: "1.0", - id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", - source: "/data-plane/letter-rendering/test", - subject: "client/client1/letter-request/letterRequest1", - type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", - time: now, + ...createPreparedV1Event(overrides), + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2", dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", - dataschemaversion: "1.0.0", + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.0.schema.json", + dataschemaversion: "2.0.0", + }; +} + +function createSupplierStatusChangeEvent( + overrides: Partial = {}, +): LetterEvent { + const now = new Date().toISOString(); + + return $LetterEvent.parse({ data: { - domainId: overrides.domainId ?? "letter1", - letterVariantId: overrides.letterVariantId ?? "lv1", - requestId: overrides.requestId ?? "request1", - requestItemId: overrides.requestItemId ?? "requestItem1", - requestItemPlanId: overrides.requestItemPlanId ?? "requestItemPlan1", - clientId: overrides.clientId ?? "client1", - campaignId: overrides.campaignId ?? "campaign1", - templateId: overrides.templateId ?? "template1", - url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", - sha256Hash: - "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", - createdAt: now, - pageCount: 1, - status: "PREPARED", + domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479", + groupId: "client_template", + origin: { + domain: "letter-rendering", + event: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + source: "/data-plane/letter-rendering/prod/render-pdf", + subject: + "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5", + }, + reasonCode: "R07", + reasonText: "No such address", + specificationId: "1y3q9v1zzzz", + status: "RETURNED", + supplierId: "supplier1", }, - traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", + datacontenttype: "application/json", + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.RETURNED.1.0.0.schema.json", + dataschemaversion: "1.0.0", + id: overrides.id ?? "23f1f09c-a555-4d9b-8405-0b33490bc920", + plane: "data", recordedtime: now, severitynumber: 2, severitytext: "INFO", - plane: "data", - }; + source: "/data-plane/supplier-api/prod/update-status", + specversion: "1.0", + subject: + "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479", + time: now, + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + type: "uk.nhs.notify.supplier-api.letter.RETURNED.v1", + }); } describe("createUpsertLetterHandler", () => { const mockedDeps: jest.Mocked = { letterRepo: { - upsertLetter: jest.fn(), + putLetter: jest.fn(), + updateLetterStatus: jest.fn(), } as unknown as LetterRepository, logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, env: { @@ -151,19 +172,11 @@ describe("createUpsertLetterHandler", () => { Records: [ createSqsRecord( "msg1", - JSON.stringify(createNotification(createValidV2Event())), + JSON.stringify(createNotification(createPreparedV2Event())), ), createSqsRecord( "msg2", - JSON.stringify( - createNotification( - createValidV2Event({ - id: "7b9a03ca-342a-4150-b56b-989109c45614", - domainId: "letter2", - url: "s3://letterDataBucket/letter2.pdf", - }), - ), - ), + JSON.stringify(createNotification(createSupplierStatusChangeEvent())), ), ], }; @@ -178,9 +191,10 @@ describe("createUpsertLetterHandler", () => { if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(0); - expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(1); + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledTimes(1); - const firstArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock .calls[0][0]; expect(firstArg.id).toBe("letter1"); expect(firstArg.supplierId).toBe("supplier1"); @@ -190,16 +204,13 @@ describe("createUpsertLetterHandler", () => { expect(firstArg.groupId).toBe("client1campaign1template1"); expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); - const secondArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock - .calls[1][0]; - expect(secondArg.id).toBe("letter2"); + const secondArg = (mockedDeps.letterRepo.updateLetterStatus as jest.Mock) + .mock.calls[0][0]; + expect(secondArg.id).toBe("f47ac10b-58cc-4372-a567-0e02b2c3d479"); expect(secondArg.supplierId).toBe("supplier1"); - expect(secondArg.specificationId).toBe("spec1"); - expect(secondArg.url).toBe("s3://letterDataBucket/letter2.pdf"); - expect(secondArg.status).toBe("PENDING"); - expect(secondArg.groupId).toBe("client1campaign1template1"); - expect(secondArg.groupId).toBe("client1campaign1template1"); - expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + expect(secondArg.status).toBe("RETURNED"); + expect(secondArg.reasonCode).toBe("R07"); + expect(secondArg.reasonText).toBe("No such address"); }); test("processes all v1 records successfully and returns no batch failures", async () => { @@ -207,13 +218,13 @@ describe("createUpsertLetterHandler", () => { Records: [ createSqsRecord( "msg1", - JSON.stringify(createNotification(createValidV1Event())), + JSON.stringify(createNotification(createPreparedV1Event())), ), createSqsRecord( "msg2", JSON.stringify( createNotification( - createValidV1Event({ + createPreparedV1Event({ id: "7b9a03ca-342a-4150-b56b-989109c45614", domainId: "letter2", url: "s3://letterDataBucket/letter2.pdf", @@ -234,9 +245,9 @@ describe("createUpsertLetterHandler", () => { if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(0); - expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(2); - const firstArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock .calls[0][0]; expect(firstArg.id).toBe("letter1"); expect(firstArg.supplierId).toBe("supplier1"); @@ -246,7 +257,7 @@ describe("createUpsertLetterHandler", () => { expect(firstArg.groupId).toBe("client1campaign1template1"); expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); - const secondArg = (mockedDeps.letterRepo.upsertLetter as jest.Mock).mock + const secondArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock .calls[1][0]; expect(secondArg.id).toBe("letter2"); expect(secondArg.supplierId).toBe("supplier1"); @@ -275,17 +286,17 @@ describe("createUpsertLetterHandler", () => { expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json"); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( - "Error processing upsert", + "Error processing upsert of record bad-json", ); - expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); }); test("invalid notification schema produces batch failure and logs error", async () => { const evt: SQSEvent = { Records: [ createSqsRecord( - "bad-schema", - JSON.stringify({ not: "the expected shape" }), + "bad-notification-schema", + JSON.stringify({ not: "unexpected notification shape" }), ), ], }; @@ -299,22 +310,54 @@ describe("createUpsertLetterHandler", () => { expect(result).toBeDefined(); if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); + expect(result.batchItemFailures[0].itemIdentifier).toBe( + "bad-notification-schema", + ); + + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-notification-schema", + ); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + }); + + test("no event type produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-event-type", + JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ no: "type" }), + }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-type"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( - "Error processing upsert", + "Error processing upsert of record bad-event-type", ); - expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); }); - test("invalid event schema produces batch failure and logs error", async () => { + test("invalid event type produces batch failure and logs error", async () => { const evt: SQSEvent = { Records: [ createSqsRecord( - "bad-schema", + "bad-event-type", JSON.stringify({ Type: "Notification", - Message: JSON.stringify({ bad: "shape" }), + Message: JSON.stringify({ type: "unexpected type" }), }), ), ], @@ -329,19 +372,49 @@ describe("createUpsertLetterHandler", () => { expect(result).toBeDefined(); if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-schema"); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-type"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-event-type", + ); + }); - expect((mockedDeps.logger.info as jest.Mock).mock.calls[0][1]).toBe( - "Trying to parse message with V1 schema", + test("valid event type and invalid schema produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-event-schema", + JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ + type: "uk.nhs.notify.letter-rendering.letter-request.prepared", + some: "unexpected shape", + }), + }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-schema"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( - "Error parsing letter event in upsert", + "Error processing upsert of record bad-event-schema", ); - expect(mockedDeps.letterRepo.upsertLetter).not.toHaveBeenCalled(); }); test("repository throwing for one record causes that message to be returned in batch failures while others succeed", async () => { - (mockedDeps.letterRepo.upsertLetter as jest.Mock) + (mockedDeps.letterRepo.putLetter as jest.Mock) .mockResolvedValueOnce({}) .mockRejectedValueOnce(new Error("ddb error")); @@ -351,9 +424,9 @@ describe("createUpsertLetterHandler", () => { "ok-msg", JSON.stringify( createNotification( - createValidV2Event({ + createPreparedV2Event({ id: "7b9a03ca-342a-4150-b56b-989109c45615", - data: { domainId: "ok" }, + domainId: "ok", }), ), ), @@ -362,9 +435,9 @@ describe("createUpsertLetterHandler", () => { "fail-msg", JSON.stringify( createNotification( - createValidV2Event({ + createPreparedV2Event({ id: "7b9a03ca-342a-4150-b56b-989109c45616", - data: { domainId: "ok" }, + domainId: "fail", }), ), ), @@ -378,7 +451,7 @@ describe("createUpsertLetterHandler", () => { {} as any, ); - expect(mockedDeps.letterRepo.upsertLetter).toHaveBeenCalledTimes(2); + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(2); if (!result) throw new Error("expected BatchResponse, got void"); expect(result.batchItemFailures).toHaveLength(1); diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index a07bc7f7..26335450 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -3,26 +3,73 @@ import { SQSBatchItemFailure, SQSEvent, SQSHandler, + SQSRecord, } from "aws-lambda"; -import { UpsertLetter } from "@internal/datastore"; +import { InsertLetter, UpdateLetter } from "@internal/datastore"; import { $LetterRequestPreparedEvent, LetterRequestPreparedEvent, } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; import { $LetterRequestPreparedEventV2, LetterRequestPreparedEventV2, } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; -import { ZodError } from "zod"; +import z from "zod"; import { Deps } from "../config/deps"; type SupplierSpec = { supplierId: string; specId: string }; +type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +type UpsertOperation = { + name: "Insert" | "Update"; + schemas: z.ZodSchema[]; + handler: (request: unknown, deps: Deps) => Promise; +}; + +// small envelope that must exist in all inputs +const TypeEnvelope = z.object({ type: z.string().min(1) }); -function mapToUpsertLetter( - upsertRequest: LetterRequestPreparedEventV2 | LetterRequestPreparedEvent, +function getOperationFromType(type: string): UpsertOperation { + if (type.startsWith("uk.nhs.notify.letter-rendering.letter-request.prepared")) + return { + name: "Insert", + schemas: [$LetterRequestPreparedEventV2, $LetterRequestPreparedEvent], + handler: async (request, deps) => { + const preparedRequest = request as PreparedEvents; + const supplierSpec: SupplierSpec = resolveSupplierForVariant( + preparedRequest.data.letterVariantId, + deps, + ); + const letterToInsert: InsertLetter = mapToInsertLetter( + preparedRequest, + supplierSpec.supplierId, + supplierSpec.specId, + ); + await deps.letterRepo.putLetter(letterToInsert); + }, + }; + if (type.startsWith("uk.nhs.notify.supplier-api.letter")) + return { + name: "Update", + schemas: [$LetterEvent], + handler: async (request, deps) => { + const supplierEvent = request as LetterEvent; + const letterToUpdate: UpdateLetter = mapToUpdateLetter(supplierEvent); + await deps.letterRepo.updateLetterStatus(letterToUpdate); + }, + }; + throw new Error(`Unknown operation from type=${type}`); +} + +function mapToInsertLetter( + upsertRequest: PreparedEvents, supplier: string, spec: string, -): UpsertLetter { +): InsertLetter { + const now = new Date().toISOString(); return { id: upsertRequest.data.domainId, supplierId: supplier, @@ -35,6 +82,18 @@ function mapToUpsertLetter( url: upsertRequest.data.url, source: upsertRequest.source, subject: upsertRequest.subject, + createdAt: now, + updatedAt: now, + }; +} + +function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter { + return { + id: upsertRequest.data.domainId, + supplierId: upsertRequest.data.supplierId, + status: upsertRequest.data.status, + reasonCode: upsertRequest.data.reasonCode, + reasonText: upsertRequest.data.reasonText, }; } @@ -45,25 +104,41 @@ function resolveSupplierForVariant( return deps.env.VARIANT_MAP[variantId]; } -function parseLetterRequestPreparedEvent( - message: string, - deps: Deps, -): LetterRequestPreparedEvent | LetterRequestPreparedEventV2 { - const parsedMessage = JSON.parse(message); - - try { - const upsertRequest: LetterRequestPreparedEventV2 = - $LetterRequestPreparedEventV2.parse(parsedMessage); - return upsertRequest; - } catch (error) { - deps.logger.info( - { err: error, message }, - "Trying to parse message with V1 schema", +function parseSNSNotification(record: SQSRecord) { + const notification = JSON.parse(record.body) as Partial; + if ( + notification.Type !== "Notification" || + typeof notification.Message !== "string" + ) { + throw new Error( + "SQS record does not contain SNS Notification with string Message", ); - const upsertRequest: LetterRequestPreparedEvent = - $LetterRequestPreparedEvent.parse(parsedMessage); - return upsertRequest; } + return notification.Message; +} + +function getType(event: unknown) { + const env = TypeEnvelope.safeParse(event); + if (!env.success) { + throw new Error("Missing or invalid envelope.type field"); + } + return env.data.type; +} + +async function runUpsert( + operation: UpsertOperation, + letterEvent: unknown, + deps: Deps, +) { + for (const schema of operation.schemas) { + const r = schema.safeParse(letterEvent); + if (r.success) { + await operation.handler(r.data, deps); + return; + } + } + // none matched + throw new Error("No matching schema for received message"); } export default function createUpsertLetterHandler(deps: Deps): SQSHandler { @@ -72,43 +147,20 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler { const tasks = event.Records.map(async (record) => { try { - const notification = JSON.parse(record.body) as Partial; - if ( - notification.Type !== "Notification" || - typeof notification.Message !== "string" - ) { - throw new Error( - "SQS record does not contain SNS Notification with string Message", - ); - } - - const upsertRequest: - | LetterRequestPreparedEvent - | LetterRequestPreparedEventV2 = parseLetterRequestPreparedEvent( - notification.Message, - deps, - ); + const message: string = parseSNSNotification(record); - const supplierSpec: SupplierSpec = resolveSupplierForVariant( - upsertRequest.data.letterVariantId, - deps, - ); - const letterToUpsert: UpsertLetter = mapToUpsertLetter( - upsertRequest, - supplierSpec.supplierId, - supplierSpec.specId, - ); + const letterEvent: unknown = JSON.parse(message); + + const type = getType(letterEvent); - await deps.letterRepo.upsertLetter(letterToUpsert); + const operation = getOperationFromType(type); + + await runUpsert(operation, letterEvent, deps); } catch (error) { - if (error instanceof ZodError) { - deps.logger.error( - { issues: error.issues, body: record.body }, - "Error parsing letter event in upsert", - ); - } else { - deps.logger.error({ err: error }, "Error processing upsert"); - } + deps.logger.error( + { err: error, message: record.body }, + `Error processing upsert of record ${record.messageId}`, + ); batchItemFailures.push({ itemIdentifier: record.messageId }); } }); diff --git a/package-lock.json b/package-lock.json index 7a57869b..e50172a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3023,6 +3023,7 @@ "@internal/datastore": "*", "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.5", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", From b1e1195ec78ccce65bf141c31c2619fca010b89f Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 18 Dec 2025 13:52:17 +0000 Subject: [PATCH 33/37] Letter variant map to vars --- infrastructure/terraform/components/api/README.md | 2 +- .../components/api/module_lambda_upsert_letter.tf | 11 +---------- infrastructure/terraform/components/api/variables.tf | 9 +++++++++ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 35e9c26a..2196692d 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -22,6 +22,7 @@ No requirements. | [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | | [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | | [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no | +| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | | [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | | [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no | @@ -30,7 +31,6 @@ 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 | -| [variant\_map](#input\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index e42968a3..2e1fb32a 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -36,19 +36,10 @@ module "upsert_letter" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - VARIANT_MAP = jsonencode(var.variant_map) + VARIANT_MAP = jsonencode(var.letter_variant_map) }) } -variable "variant_map" { - type = map(object({ supplierId = string, specId = string })) - default = { - "lv1" = { supplierId = "supplier1", specId = "spec1" }, - "lv2" = { supplierId = "supplier1", specId = "spec2" }, - "lv3" = { supplierId = "supplier2", specId = "spec3" } - } -} - data "aws_iam_policy_document" "upsert_letter_lambda" { statement { sid = "KMSPermissions" diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 5c3bb9a8..39441538 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -134,3 +134,12 @@ variable "eventpub_control_plane_bus_arn" { description = "ARN of the EventBridge control plane bus for eventpub" default = "" } + +variable "letter_variant_map" { + type = map(object({ supplierId = string, specId = string })) + default = { + "lv1" = { supplierId = "supplier1", specId = "spec1" }, + "lv2" = { supplierId = "supplier1", specId = "spec2" }, + "lv3" = { supplierId = "supplier2", specId = "spec3" } + } +} From fdd73fc2125086161b7a2fe667388c8b96ceddc2 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 18 Dec 2025 13:53:54 +0000 Subject: [PATCH 34/37] Revert supplierStatusSk --- .../src/__test__/letter-repository.test.ts | 66 ++++++++++++++----- internal/datastore/src/letter-repository.ts | 4 +- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 7c46ff96..5e92e685 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -14,8 +14,8 @@ function createLetter( supplierId: string, letterId: string, status: Letter["status"] = "PENDING", - date: string = new Date().toISOString(), ): InsertLetter { + const now = new Date().toISOString(); return { id: letterId, supplierId, @@ -23,13 +23,18 @@ function createLetter( groupId: "group1", url: `s3://bucket/${letterId}.pdf`, status, - createdAt: date, - updatedAt: date, + createdAt: now, + updatedAt: now, source: "/data-plane/letter-rendering/pdf", subject: `client/1/letter-request/${letterId}`, }; } +function assertDateBetween(date: number, before: number, after: number) { + expect(date).toBeGreaterThanOrEqual(before); + expect(date).toBeLessThanOrEqual(after); +} + // Database tests can take longer, especially with setup and teardown jest.setTimeout(30_000); @@ -82,19 +87,24 @@ describe("LetterRepository", () => { test("adds a letter to the database", async () => { const supplierId = "supplier1"; const letterId = "letter1"; - const date = new Date().toISOString(); - await letterRepository.putLetter( - createLetter(supplierId, letterId, "PENDING", date), - ); + const before = Date.now(); + + await letterRepository.putLetter(createLetter(supplierId, letterId)); + + const after = Date.now(); const letter = await letterRepository.getLetterById(supplierId, letterId); expect(letter).toBeDefined(); expect(letter.id).toBe(letterId); expect(letter.supplierId).toBe(supplierId); - expect(letter.createdAt).toBe(date); - expect(letter.updatedAt).toBe(date); - expect(letter.supplierStatusSk).toBe(date); + assertDateBetween(new Date(letter.createdAt).valueOf(), before, after); + assertDateBetween(new Date(letter.updatedAt).valueOf(), before, after); + assertDateBetween( + new Date(letter.supplierStatusSk).valueOf(), + before, + after, + ); expect(letter.supplierStatus).toBe("supplier1#PENDING"); expect(letter.url).toBe("s3://bucket/letter1.pdf"); expect(letter.specificationId).toBe("specification1"); @@ -102,6 +112,7 @@ describe("LetterRepository", () => { expect(letter.reasonCode).toBeUndefined(); expect(letter.reasonText).toBeUndefined(); expect(letter.subject).toBe(`client/1/letter-request/${letterId}`); + assertTtl(letter.ttl, before, after); }); test("fetches a letter by id", async () => { @@ -149,7 +160,7 @@ describe("LetterRepository", () => { }); test("updates a letter's status in the database", async () => { - const letter = createLetter("supplier1", "letter1", "PENDING"); + const letter = createLetter("supplier1", "letter1"); await letterRepository.putLetter(letter); await checkLetterStatus("supplier1", "letter1", "PENDING"); @@ -174,9 +185,7 @@ describe("LetterRepository", () => { test("updates a letter's updatedAt date", async () => { jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - await letterRepository.putLetter( - createLetter("supplier1", "letter1", "PENDING"), - ); + await letterRepository.putLetter(createLetter("supplier1", "letter1")); const originalLetter = await letterRepository.getLetterById( "supplier1", "letter1", @@ -432,15 +441,36 @@ describe("LetterRepository", () => { }); test("should batch write letters to the database", async () => { - const letters = [ + const before = Date.now(); + + await letterRepository.putLetterBatch([ createLetter("supplier1", "letter1"), createLetter("supplier1", "letter2"), createLetter("supplier1", "letter3"), - ]; + ]); - await letterRepository.putLetterBatch(letters); + const after = Date.now(); + + const letter = await letterRepository.getLetterById("supplier1", "letter1"); + expect(letter).toBeDefined(); + expect(letter.id).toBe("letter1"); + expect(letter.supplierId).toBe("supplier1"); + assertDateBetween(new Date(letter.createdAt).valueOf(), before, after); + assertDateBetween(new Date(letter.updatedAt).valueOf(), before, after); + assertDateBetween( + new Date(letter.supplierStatusSk).valueOf(), + before, + after, + ); + expect(letter.supplierStatus).toBe("supplier1#PENDING"); + expect(letter.url).toBe("s3://bucket/letter1.pdf"); + expect(letter.specificationId).toBe("specification1"); + expect(letter.groupId).toBe("group1"); + expect(letter.reasonCode).toBeUndefined(); + expect(letter.reasonText).toBeUndefined(); + expect(letter.subject).toBe("client/1/letter-request/letter1"); + assertTtl(letter.ttl, before, after); - await checkLetterStatus("supplier1", "letter1", "PENDING"); await checkLetterStatus("supplier1", "letter2", "PENDING"); await checkLetterStatus("supplier1", "letter3", "PENDING"); }); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 66796b5e..547b1788 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -43,7 +43,7 @@ export class LetterRepository { const letterDb: Letter = { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: letter.createdAt, // needs to be an ISO timestamp + supplierStatusSk: new Date().toISOString(), // needs to be an ISO timestamp as Db sorts alphabetically ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), @@ -79,7 +79,7 @@ export class LetterRepository { lettersDb.push({ ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: letter.createdAt, + supplierStatusSk: new Date().toISOString(), // needs to be an ISO timestamp as Db sorts alphabetically ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), From 10ec08fa147681e34d70643cc7d65baedb2a2f21 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 18 Dec 2025 18:17:41 +0000 Subject: [PATCH 35/37] Fix dependencies --- package-lock.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/package-lock.json b/package-lock.json index a8210a52..06d503e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2965,6 +2965,28 @@ "@esbuild/win32-x64": "0.24.2" } }, + "lambdas/upsert-letter/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", From e42d26c46f0b387bec73d70d83710e10fcaa2600 Mon Sep 17 00:00:00 2001 From: Mark Slowey <113013138+masl2@users.noreply.github.com> Date: Wed, 24 Dec 2025 11:03:13 +0000 Subject: [PATCH 36/37] CCM-13697: Letters Key and Source Data Item (#323) * swap partition and sort for letters * store changes and source, subject, billingref * unsafe marker * rename correctly --- .../terraform/components/api/ddb_table_letters.tf | 4 ++-- internal/datastore/src/__test__/db.ts | 4 ++-- internal/datastore/src/__test__/heathcheck.test.ts | 4 ++++ .../datastore/src/__test__/letter-repository.test.ts | 10 ++++++---- internal/datastore/src/letter-repository.ts | 6 +++--- internal/datastore/src/types.ts | 1 + internal/events/package.json | 2 +- internal/events/schemas/examples/letter.ACCEPTED.json | 1 + internal/events/schemas/examples/letter.FORWARDED.json | 1 + internal/events/schemas/examples/letter.RETURNED.json | 1 + internal/events/src/domain/letter.ts | 7 +++++++ .../__tests__/letter-status-change-events.test.ts | 1 + .../letter.ACCEPTED-with-invalid-major-version.json | 1 + .../testData/letter.ACCEPTED-with-missing-fields.json | 1 + .../src/events/__tests__/testData/letter.ACCEPTED.json | 1 + .../events/__tests__/testData/letter.FORWARDED.json | 1 + .../src/events/__tests__/testData/letter.RETURNED.json | 1 + .../src/mappers/__tests__/letter-mapper.test.ts | 10 ++++++++++ .../src/services/__tests__/letter-operations.test.ts | 2 ++ .../src/__tests__/letter-updates-transformer.test.ts | 5 +++++ .../src/mappers/__tests__/letter-mapper.test.ts | 10 +++++++--- .../src/mappers/letter-mapper.ts | 5 +++-- lambdas/letter-updates-transformer/src/types.ts | 10 +++++----- .../src/handler/__tests__/upsert-handler.test.ts | 1 + lambdas/upsert-letter/src/handler/upsert-handler.ts | 3 +++ .../src/__test__/helpers/create_letter_helpers.test.ts | 4 ++++ scripts/utilities/letter-test-data/src/cli/index.ts | 2 +- .../src/helpers/create_letter_helpers.ts | 4 ++++ 28 files changed, 80 insertions(+), 23 deletions(-) diff --git a/infrastructure/terraform/components/api/ddb_table_letters.tf b/infrastructure/terraform/components/api/ddb_table_letters.tf index 6a3c3e48..44c882c4 100644 --- a/infrastructure/terraform/components/api/ddb_table_letters.tf +++ b/infrastructure/terraform/components/api/ddb_table_letters.tf @@ -2,8 +2,8 @@ resource "aws_dynamodb_table" "letters" { name = "${local.csi}-letters" billing_mode = "PAY_PER_REQUEST" - hash_key = "supplierId" - range_key = "id" + hash_key = "id" + range_key = "supplierId" ttl { attribute_name = "ttl" diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index f3606b92..1d364b9f 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -51,8 +51,8 @@ const createLetterTableCommand = new CreateTableCommand({ TableName: "letters", BillingMode: "PAY_PER_REQUEST", KeySchema: [ - { AttributeName: "supplierId", KeyType: "HASH" }, // Partition key - { AttributeName: "id", KeyType: "RANGE" }, // Sort key + { AttributeName: "id", KeyType: "HASH" }, // Partition key (letter ID) + { AttributeName: "supplierId", KeyType: "RANGE" }, // Sort key ], GlobalSecondaryIndexes: [ { diff --git a/internal/datastore/src/__test__/heathcheck.test.ts b/internal/datastore/src/__test__/heathcheck.test.ts index 315144d7..40545364 100644 --- a/internal/datastore/src/__test__/heathcheck.test.ts +++ b/internal/datastore/src/__test__/heathcheck.test.ts @@ -24,6 +24,10 @@ describe("DBHealthcheck", () => { await deleteTables(db); }); + afterAll(async () => { + await db.container.stop(); + }); + it("passes when the database is available", async () => { const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); await expect(dbHealthCheck.check()).resolves.not.toThrow(); diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 5e92e685..393c3810 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -27,6 +27,7 @@ function createLetter( updatedAt: now, source: "/data-plane/letter-rendering/pdf", subject: `client/1/letter-request/${letterId}`, + billingRef: "specification1", }; } @@ -112,6 +113,7 @@ describe("LetterRepository", () => { expect(letter.reasonCode).toBeUndefined(); expect(letter.reasonText).toBeUndefined(); expect(letter.subject).toBe(`client/1/letter-request/${letterId}`); + expect(letter.billingRef).toBe("specification1"); assertTtl(letter.ttl, before, after); }); @@ -443,7 +445,7 @@ describe("LetterRepository", () => { test("should batch write letters to the database", async () => { const before = Date.now(); - await letterRepository.putLetterBatch([ + await letterRepository.unsafePutLetterBatch([ createLetter("supplier1", "letter1"), createLetter("supplier1", "letter2"), createLetter("supplier1", "letter3"), @@ -483,7 +485,7 @@ describe("LetterRepository", () => { const sendSpy = jest.spyOn(db.docClient, "send"); - await letterRepository.putLetterBatch(letters); + await letterRepository.unsafePutLetterBatch(letters); expect(sendSpy).toHaveBeenCalledTimes(3); @@ -497,7 +499,7 @@ describe("LetterRepository", () => { letters[0] = createLetter("supplier1", "letter1"); letters[2] = createLetter("supplier1", "letter3"); - await letterRepository.putLetterBatch(letters); + await letterRepository.unsafePutLetterBatch(letters); await checkLetterStatus("supplier1", "letter1", "PENDING"); await checkLetterStatus("supplier1", "letter3", "PENDING"); @@ -509,7 +511,7 @@ describe("LetterRepository", () => { lettersTableName: "nonexistent-table", }); await expect( - misconfiguredRepository.putLetterBatch([ + misconfiguredRepository.unsafePutLetterBatch([ createLetter("supplier1", "letter1"), ]), ).rejects.toThrow("Cannot do operations on a non-existent table"); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 547b1788..d3af885e 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -70,7 +70,7 @@ export class LetterRepository { return LetterSchema.parse(letterDb); } - async putLetterBatch(letters: InsertLetter[]): Promise { + async unsafePutLetterBatch(letters: InsertLetter[]): Promise { let lettersDb: Letter[] = []; for (let i = 0; i < letters.length; i++) { const letter = letters[i]; @@ -109,8 +109,8 @@ export class LetterRepository { new GetCommand({ TableName: this.config.lettersTableName, Key: { - supplierId, id: letterId, + supplierId, }, }), ); @@ -194,8 +194,8 @@ export class LetterRepository { new UpdateCommand({ TableName: this.config.lettersTableName, Key: { - supplierId: letterToUpdate.supplierId, id: letterToUpdate.id, + supplierId: letterToUpdate.supplierId, }, UpdateExpression: updateExpression, ConditionExpression: "attribute_exists(id)", // Ensure letter exists diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index a77e8e66..a0b9f719 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -50,6 +50,7 @@ export const LetterSchema = LetterSchemaBase.extend({ ttl: z.int(), source: z.string(), subject: z.string(), + billingRef: z.string(), }).describe("Letter"); /** diff --git a/internal/events/package.json b/internal/events/package.json index 2d799646..8005d13e 100644 --- a/internal/events/package.json +++ b/internal/events/package.json @@ -50,5 +50,5 @@ "typecheck": "tsc --noEmit" }, "types": "dist/index.d.ts", - "version": "1.0.5" + "version": "1.0.6" } diff --git a/internal/events/schemas/examples/letter.ACCEPTED.json b/internal/events/schemas/examples/letter.ACCEPTED.json index f9a1177c..c6533b93 100644 --- a/internal/events/schemas/examples/letter.ACCEPTED.json +++ b/internal/events/schemas/examples/letter.ACCEPTED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/schemas/examples/letter.FORWARDED.json b/internal/events/schemas/examples/letter.FORWARDED.json index bf12ed69..6661fe6c 100644 --- a/internal/events/schemas/examples/letter.FORWARDED.json +++ b/internal/events/schemas/examples/letter.FORWARDED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/schemas/examples/letter.RETURNED.json b/internal/events/schemas/examples/letter.RETURNED.json index e273029d..f0cfa376 100644 --- a/internal/events/schemas/examples/letter.RETURNED.json +++ b/internal/events/schemas/examples/letter.RETURNED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/domain/letter.ts b/internal/events/src/domain/letter.ts index 7e854d41..67ed8bbe 100644 --- a/internal/events/src/domain/letter.ts +++ b/internal/events/src/domain/letter.ts @@ -76,6 +76,13 @@ The identifier will be included as the origin domain in the subject of any corre examples: ["1y3q9v1zzzz"], }), + billingRef: z.string().meta({ + title: "Billing Reference", + description: + "A billing reference determined for this letter based on its specification", + examples: ["1y3q9v1zzzz"], + }), + supplierId: z.string().meta({ title: "Supplier ID", description: "Supplier ID allocated to the letter during creation.", diff --git a/internal/events/src/events/__tests__/letter-status-change-events.test.ts b/internal/events/src/events/__tests__/letter-status-change-events.test.ts index 25541fc5..48215545 100644 --- a/internal/events/src/events/__tests__/letter-status-change-events.test.ts +++ b/internal/events/src/events/__tests__/letter-status-change-events.test.ts @@ -37,6 +37,7 @@ describe("LetterStatus event validations", () => { }), domainId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", specificationId: "1y3q9v1zzzz", + billingRef: "1y3q9v1zzzz", groupId: "client_template", status, }), diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json index 5458449d..192ea5e2 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json index b7a1358b..54000422 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json index e39b8366..7ffac10f 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json index 6b7b4c45..28c6111f 100644 --- a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json +++ b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.RETURNED.json b/internal/events/src/events/__tests__/testData/letter.RETURNED.json index 8a4a9e44..07b28154 100644 --- a/internal/events/src/events/__tests__/testData/letter.RETURNED.json +++ b/internal/events/src/events/__tests__/testData/letter.RETURNED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index 6f28ccf7..fa7f9f81 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -18,6 +18,7 @@ describe("letter-mapper", () => { status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", createdAt: date, @@ -26,6 +27,7 @@ describe("letter-mapper", () => { supplierStatusSk: date, ttl: 123, source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -50,6 +52,7 @@ describe("letter-mapper", () => { status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", createdAt: date, @@ -60,6 +63,7 @@ describe("letter-mapper", () => { reasonCode: "R01", reasonText: "Reason text", source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -86,6 +90,7 @@ describe("letter-mapper", () => { status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", createdAt: date, @@ -94,6 +99,7 @@ describe("letter-mapper", () => { supplierStatusSk: date, ttl: 123, source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -118,6 +124,7 @@ describe("letter-mapper", () => { status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", createdAt: date, @@ -128,6 +135,7 @@ describe("letter-mapper", () => { reasonCode: "R01", reasonText: "Reason text", source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -154,6 +162,7 @@ describe("letter-mapper", () => { status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", createdAt: date, @@ -164,6 +173,7 @@ describe("letter-mapper", () => { reasonCode: "R01", reasonText: "Reason text", source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLettersResponse = mapToGetLettersResponse([ diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 185e462d..c7cfd592 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -29,6 +29,7 @@ function makeLetter(id: string, status: Letter["status"]): Letter { status, supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: `s3://letterDataBucket/${id}.pdf`, createdAt: new Date().toISOString(), @@ -39,6 +40,7 @@ function makeLetter(id: string, status: Letter["status"]): Letter { reasonCode: "R01", reasonText: "Reason text", source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; } 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 17c271a0..67211462 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 @@ -257,9 +257,14 @@ function generateLetter(status: LetterStatus, id?: string): LetterForEventPub { id: id || "1", status, specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", + createdAt: "2025-12-10T11:12:54Z", updatedAt: "2025-12-10T11:13:54Z", + url: "https://example.com/letter.pdf", + source: "test-source", + subject: "test-source/subject-id", }; } diff --git a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts index fe5b5a79..7139165d 100644 --- a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts @@ -7,12 +7,15 @@ describe("letter-mapper", () => { const letter = { id: "id1", specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", status: "PRINTED", reasonCode: "R02", reasonText: "Reason text", updatedAt: "2025-11-24T15:55:18.000Z", + source: "letter-rendering/source/test", + subject: "letter-rendering/source/letter/letter-id", } as Letter; const event = mapLetterToCloudEvent(letter); @@ -22,7 +25,7 @@ describe("letter-mapper", () => { expect(event.dataschema).toBe( `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.PRINTED.${event.dataschemaversion}.schema.json`, ); - expect(event.dataschemaversion).toBe("1.0.5"); + expect(event.dataschemaversion).toBe("1.0.6"); expect(event.subject).toBe("letter-origin/supplier-api/letter/id1"); expect(event.time).toBe("2025-11-24T15:55:18.000Z"); expect(event.recordedtime).toBe("2025-11-24T15:55:18.000Z"); @@ -30,14 +33,15 @@ describe("letter-mapper", () => { domainId: "id1", status: "PRINTED", specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", reasonCode: "R02", reasonText: "Reason text", origin: { domain: "supplier-api", - source: "/data-plane/supplier-api/letters", - subject: "letter-origin/supplier-api/letter/id1", + source: "letter-rendering/source/test", + subject: "letter-rendering/source/letter/letter-id", event: event.id, }, }); diff --git a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts b/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts index 759f5f0f..34cd23c5 100644 --- a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts +++ b/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts @@ -22,14 +22,15 @@ export default function mapLetterToCloudEvent( domainId: letter.id as LetterEvent["data"]["domainId"], status: letter.status, specificationId: letter.specificationId, + billingRef: letter.billingRef, supplierId: letter.supplierId, groupId: letter.groupId, reasonCode: letter.reasonCode, reasonText: letter.reasonText, origin: { domain: "supplier-api", - source: "/data-plane/supplier-api/letters", - subject: `letter-origin/supplier-api/letter/${letter.id}`, + source: letter.source, + subject: letter.subject, event: eventId, }, }, diff --git a/lambdas/letter-updates-transformer/src/types.ts b/lambdas/letter-updates-transformer/src/types.ts index b1b7f4c7..34920991 100644 --- a/lambdas/letter-updates-transformer/src/types.ts +++ b/lambdas/letter-updates-transformer/src/types.ts @@ -1,10 +1,10 @@ -import { LetterSchemaBase, SupplierSchema } from "@internal/datastore"; -import { idRef } from "@internal/helpers"; +import { LetterSchema } from "@internal/datastore"; import { z } from "zod"; -export const LetterSchemaForEventPub = LetterSchemaBase.extend({ - supplierId: idRef(SupplierSchema, "id"), - updatedAt: z.string(), +export const LetterSchemaForEventPub = LetterSchema.omit({ + supplierStatus: true, + supplierStatusSk: true, + ttl: true, }); export type LetterForEventPub = z.infer; 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 78197a0a..a185e058 100644 --- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -122,6 +122,7 @@ function createSupplierStatusChangeEvent( reasonCode: "R07", reasonText: "No such address", specificationId: "1y3q9v1zzzz", + billingRef: "1y3q9v1zzzz", status: "RETURNED", supplierId: "supplier1", }, diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index 26335450..141e37ce 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -47,6 +47,7 @@ function getOperationFromType(type: string): UpsertOperation { preparedRequest, supplierSpec.supplierId, supplierSpec.specId, + supplierSpec.specId, //use specId for now ); await deps.letterRepo.putLetter(letterToInsert); }, @@ -68,6 +69,7 @@ function mapToInsertLetter( upsertRequest: PreparedEvents, supplier: string, spec: string, + billingRef: string, ): InsertLetter { const now = new Date().toISOString(); return { @@ -84,6 +86,7 @@ function mapToInsertLetter( subject: upsertRequest.subject, createdAt: now, updatedAt: now, + billingRef: billingRef, }; } diff --git a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts index b677fd5a..ad34b021 100644 --- a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts +++ b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts @@ -55,6 +55,8 @@ describe("Create letter helpers", () => { updatedAt: "2020-02-01T00:00:00.000Z", url: "s3://bucketName/supplierId/targetFilename", source: "/data-plane/letter-rendering/letter-test-data", + subject: "supplier-api/letter-test-data/letterId", + billingRef: "specificationId" }); }); @@ -83,6 +85,8 @@ describe("Create letter helpers", () => { createdAt: "2020-02-01T00:00:00.000Z", updatedAt: "2020-02-01T00:00:00.000Z", source: "/data-plane/letter-rendering/letter-test-data", + subject: "supplier-api/letter-test-data/testLetterId", + billingRef: "testSpecId" }); }); }); diff --git a/scripts/utilities/letter-test-data/src/cli/index.ts b/scripts/utilities/letter-test-data/src/cli/index.ts index 915c392b..a3026200 100644 --- a/scripts/utilities/letter-test-data/src/cli/index.ts +++ b/scripts/utilities/letter-test-data/src/cli/index.ts @@ -174,7 +174,7 @@ async function main() { }; // Upload Letters - await letterRepository.putLetterBatch(letterDtos); + await letterRepository.unsafePutLetterBatch(letterDtos); console.log(`Created batch ${batchId} of ${letterDtos.length}`); }, diff --git a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts index 42798eca..e471bdb8 100644 --- a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts +++ b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts @@ -43,6 +43,8 @@ export async function createLetter(params: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), source: "/data-plane/letter-rendering/letter-test-data", + subject: `supplier-api/letter-test-data/${letterId}`, + billingRef: specificationId }; const letterRecord = await letterRepository.putLetter(letter); @@ -76,6 +78,8 @@ export function createLetterDto(params: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), source: "/data-plane/letter-rendering/letter-test-data", + subject: `supplier-api/letter-test-data/${letterId}`, + billingRef: specificationId }; return letter; From 448702b7c88d3df923356d20975be351b96126af Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Wed, 24 Dec 2025 11:25:25 +0000 Subject: [PATCH 37/37] missing close brace --- infrastructure/terraform/components/api/README.md | 2 ++ infrastructure/terraform/components/api/variables.tf | 1 + 2 files changed, 3 insertions(+) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 2196692d..607e80e2 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -12,6 +12,8 @@ No requirements. | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | | [ca\_pem\_filename](#input\_ca\_pem\_filename) | Filename for the CA truststore file within the s3 bucket | `string` | `null` | no | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"supapi"` | no | +| [core\_account\_id](#input\_core\_account\_id) | AWS Account ID for Core | `string` | `"000000000000"` | no | +| [core\_environment](#input\_core\_environment) | Environment of Core | `string` | `"prod"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [enable\_backups](#input\_enable\_backups) | Enable backups | `bool` | `false` | no | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 2931e524..f1a8a080 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -142,6 +142,7 @@ variable "letter_variant_map" { "lv2" = { supplierId = "supplier1", specId = "spec2" }, "lv3" = { supplierId = "supplier2", specId = "spec3" } } +} variable "core_account_id" { type = string