From 41b7382120395aa5b065e93af0f839785dbf10ed Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Tue, 3 Mar 2026 14:52:06 +0000 Subject: [PATCH 1/8] Delete from letter queue when letter no longer pending --- .../components/api/ddb_table_letter_queue.tf | 6 +- .../api/module_lambda_update_letter_queue.tf | 1 + .../__test__/letter-queue-repository.test.ts | 49 ++++++- internal/datastore/src/index.ts | 3 +- ...rors.ts => letter-already-exists-error.ts} | 0 .../src/letter-does-not-exist-error.ts | 15 +++ .../datastore/src/letter-queue-repository.ts | 29 ++++- .../src/__tests__/update-letter-queue.test.ts | 106 ++++++++++++++-- .../src/update-letter-queue.ts | 120 +++++++++++++----- 9 files changed, 274 insertions(+), 55 deletions(-) rename internal/datastore/src/{errors.ts => letter-already-exists-error.ts} (100%) create mode 100644 internal/datastore/src/letter-does-not-exist-error.ts diff --git a/infrastructure/terraform/components/api/ddb_table_letter_queue.tf b/infrastructure/terraform/components/api/ddb_table_letter_queue.tf index 64a6c34ba..19c3b2333 100644 --- a/infrastructure/terraform/components/api/ddb_table_letter_queue.tf +++ b/infrastructure/terraform/components/api/ddb_table_letter_queue.tf @@ -3,7 +3,7 @@ resource "aws_dynamodb_table" "letter_queue" { billing_mode = "PAY_PER_REQUEST" hash_key = "supplierId" - range_key = "queueTimestamp" + range_key = "letterId" ttl { attribute_name = "ttl" @@ -11,8 +11,8 @@ resource "aws_dynamodb_table" "letter_queue" { } local_secondary_index { - name = "letterId-index" - range_key = "letterId" + name = "queueTimestamp-index" + range_key = "queueTimestamp" projection_type = "ALL" } diff --git a/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf b/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf index cbd1d0ade..187f005c4 100644 --- a/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf +++ b/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf @@ -47,6 +47,7 @@ data "aws_iam_policy_document" "update_letter_queue_lambda" { actions = [ "dynamodb:PutItem", + "dynamodb:DeleteItem", ] resources = [ diff --git a/internal/datastore/src/__test__/letter-queue-repository.test.ts b/internal/datastore/src/__test__/letter-queue-repository.test.ts index fdba8e81e..0a12ca74a 100644 --- a/internal/datastore/src/__test__/letter-queue-repository.test.ts +++ b/internal/datastore/src/__test__/letter-queue-repository.test.ts @@ -1,3 +1,4 @@ +import { GetCommand } from "@aws-sdk/lib-dynamodb"; import { Logger } from "pino"; import { DBContext, @@ -7,8 +8,9 @@ import { } from "./db"; import LetterQueueRepository from "../letter-queue-repository"; import { InsertPendingLetter } from "../types"; -import { LetterAlreadyExistsError } from "../errors"; +import { LetterAlreadyExistsError } from "../letter-already-exists-error"; import { createTestLogger } from "./logs"; +import { LetterDoesNotExistError } from "../letter-does-not-exist-error"; function createLetter(letterId = "letter1"): InsertPendingLetter { return { @@ -77,6 +79,7 @@ describe("LetterQueueRepository", () => { expect(timestampInMillis).toBeGreaterThanOrEqual(before); expect(timestampInMillis).toBeLessThanOrEqual(after); assertTtl(pendingLetter.ttl, before, after); + expect(await letterExists(db, "supplier1", "letter1")).toBe(true); }); it("throws LetterAlreadyExistsError when creating a letter which already exists", async () => { @@ -101,4 +104,48 @@ describe("LetterQueueRepository", () => { ).rejects.toThrow("Cannot do operations on a non-existent table"); }); }); + + describe("deleteLetter", () => { + it("deletes a letter from the database", async () => { + await letterQueueRepository.putLetter(createLetter()); + + await letterQueueRepository.deleteLetter("supplier1", "letter1"); + + expect(await letterExists(db, "supplier1", "letter1")).toBe(false); + }); + + it("throws an error when the letter does not exist", async () => { + await expect( + letterQueueRepository.deleteLetter("supplier1", "letter1"), + ).rejects.toThrow(LetterDoesNotExistError); + }); + + it("rethrows errors from DynamoDB when deleting a letter", async () => { + const misconfiguredRepository = new LetterQueueRepository( + db.docClient, + logger, + { + ...db.config, + letterQueueTableName: "nonexistent-table", + }, + ); + await expect( + misconfiguredRepository.deleteLetter("supplier1", "letter1"), + ).rejects.toThrow("Cannot do operations on a non-existent table"); + }); + }); }); + +async function letterExists( + db: DBContext, + supplierId: string, + letterId: string, +): Promise { + const result = await db.docClient.send( + new GetCommand({ + TableName: db.config.letterQueueTableName, + Key: { supplierId, letterId }, + }), + ); + return result.Item !== undefined; +} diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 7ee912c23..72fd95d89 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -1,5 +1,6 @@ export * from "./types"; -export * from "./errors"; +export * from "./letter-already-exists-error"; +export * from "./letter-does-not-exist-error"; export * from "./mi-repository"; export * from "./letter-repository"; export * from "./supplier-repository"; diff --git a/internal/datastore/src/errors.ts b/internal/datastore/src/letter-already-exists-error.ts similarity index 100% rename from internal/datastore/src/errors.ts rename to internal/datastore/src/letter-already-exists-error.ts diff --git a/internal/datastore/src/letter-does-not-exist-error.ts b/internal/datastore/src/letter-does-not-exist-error.ts new file mode 100644 index 000000000..ab5410b91 --- /dev/null +++ b/internal/datastore/src/letter-does-not-exist-error.ts @@ -0,0 +1,15 @@ +/** + * Error thrown when attempting to delete a letter that does not exist in the database. + */ +// eslint-disable-next-line import-x/prefer-default-export +export class LetterDoesNotExistError extends Error { + constructor( + public readonly supplierId: string, + public readonly letterId: string, + ) { + super( + `Letter does not exist: supplierId=${supplierId}, letterId=${letterId}`, + ); + this.name = "LetterDoesNotExistError"; + } +} diff --git a/internal/datastore/src/letter-queue-repository.ts b/internal/datastore/src/letter-queue-repository.ts index 5e1da7dd4..6aecc3cda 100644 --- a/internal/datastore/src/letter-queue-repository.ts +++ b/internal/datastore/src/letter-queue-repository.ts @@ -1,11 +1,16 @@ -import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { + DeleteCommand, + DynamoDBDocumentClient, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; import { Logger } from "pino"; import { InsertPendingLetter, PendingLetter, PendingLetterSchema, } from "./types"; -import { LetterAlreadyExistsError } from "./errors"; +import { LetterAlreadyExistsError } from "./letter-already-exists-error"; +import { LetterDoesNotExistError } from "./letter-does-not-exist-error"; type LetterQueueRepositoryConfig = { letterQueueTableName: string; @@ -52,4 +57,24 @@ export default class LetterQueueRepository { } return PendingLetterSchema.parse(pendingLetter); } + + async deleteLetter(supplierId: string, letterId: string): Promise { + try { + await this.ddbClient.send( + new DeleteCommand({ + TableName: this.config.letterQueueTableName, + Key: { supplierId, letterId }, + ConditionExpression: "attribute_exists(letterId)", + }), + ); + } catch (error) { + if ( + error instanceof Error && + error.name === "ConditionalCheckFailedException" + ) { + throw new LetterDoesNotExistError(supplierId, letterId); + } + throw error; + } + } } diff --git a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts index 03f9ff720..c61acd83c 100644 --- a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts +++ b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts @@ -1,6 +1,7 @@ import { Letter, LetterAlreadyExistsError, + LetterDoesNotExistError, LetterQueueRepository, } from "@internal/datastore"; import { mockDeep } from "jest-mock-extended"; @@ -21,6 +22,7 @@ import { LetterStatus } from "../../../api-handler/src/contracts/letters"; const mockedDeps: jest.Mocked = { letterQueueRepository: { putLetter: jest.fn(), + deleteLetter: jest.fn(), } as unknown as LetterQueueRepository, logger: { info: jest.fn(), @@ -50,7 +52,7 @@ function generateLetter(status: LetterStatus, id?: string): Letter { } beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe("update-letter-queue Lambda", () => { @@ -74,23 +76,25 @@ describe("update-letter-queue Lambda", () => { expect(result.batchItemFailures).toEqual([]); }); - it("does not publish updates", async () => { + it("deletes letters that are no longer pending", async () => { const handler = createHandler(mockedDeps); const oldLetter = generateLetter("PENDING"); - const newLetter = generateLetter("PENDING"); + const newLetter = generateLetter("ACCEPTED"); const testData = generateKinesisEvent([ generateModifyRecord(oldLetter, newLetter), ]); const result = await handler(testData, mockDeep(), jest.fn()); - expect(mockedDeps.letterQueueRepository.putLetter).not.toHaveBeenCalled(); + expect( + mockedDeps.letterQueueRepository.deleteLetter, + ).toHaveBeenCalledWith("supplier1", "1"); expect(result.batchItemFailures).toEqual([]); }); it("does not publish non-PENDING letters", async () => { const handler = createHandler(mockedDeps); - const newLetter = generateLetter("PRINTED"); + const newLetter = generateLetter("ACCEPTED"); const testData = generateKinesisEvent([generateInsertRecord(newLetter)]); const result = await handler(testData, mockDeep(), jest.fn()); @@ -99,6 +103,22 @@ describe("update-letter-queue Lambda", () => { expect(result.batchItemFailures).toEqual([]); }); + it("does not delete letters that are still PENDING", async () => { + const handler = createHandler(mockedDeps); + const oldLetter = generateLetter("PENDING"); + const newLetter = generateLetter("PENDING"); + + const testData = generateKinesisEvent([ + generateModifyRecord(oldLetter, newLetter), + ]); + const result = await handler(testData, mockDeep(), jest.fn()); + + expect( + mockedDeps.letterQueueRepository.deleteLetter, + ).not.toHaveBeenCalled(); + expect(result.batchItemFailures).toEqual([]); + }); + it("handles empty Records array", async () => { const handler = createHandler(mockedDeps); const testData = { Records: [] } as unknown as KinesisStreamEvent; @@ -116,11 +136,10 @@ describe("update-letter-queue Lambda", () => { const newLetter = { id: "1", status: "PENDING" } as Letter; const testData = generateKinesisEvent([generateInsertRecord(newLetter)]); - await expect( - handler(testData, mockDeep(), jest.fn()), - ).rejects.toThrow(); + const result = await handler(testData, mockDeep(), jest.fn()); expect(mockedDeps.letterQueueRepository.putLetter).not.toHaveBeenCalled(); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: "seq-0" }]); }); it("returns on the first failure", async () => { @@ -143,7 +162,7 @@ describe("update-letter-queue Lambda", () => { expect(result.batchItemFailures).toEqual([{ itemIdentifier: "seq-0" }]); }); - it("does not treat a replayed event as a failure", async () => { + it("does not treat a replayed insert as a failure", async () => { const handler = createHandler(mockedDeps); const newLetter1 = generateLetter("PENDING", "1"); const newLetter2 = generateLetter("PENDING", "2"); @@ -160,6 +179,25 @@ describe("update-letter-queue Lambda", () => { expect(result.batchItemFailures).toEqual([]); }); + it("does not treat a replayed delete as a failure", async () => { + const handler = createHandler(mockedDeps); + const oldLetter1 = generateLetter("PENDING", "1"); + const oldLetter2 = generateLetter("PENDING", "2"); + const newLetter1 = generateLetter("ACCEPTED", "1"); + const newLetter2 = generateLetter("ACCEPTED", "2"); + (mockedDeps.letterQueueRepository.putLetter as jest.Mock) + .mockRejectedValueOnce(new LetterDoesNotExistError("supplier1", "1")) + .mockResolvedValueOnce({}); + + const testData = generateKinesisEvent([ + generateModifyRecord(oldLetter1, newLetter1), + generateModifyRecord(oldLetter2, newLetter2), + ]); + const result = await handler(testData, mockDeep(), jest.fn()); + + expect(result.batchItemFailures).toEqual([]); + }); + it("throws error when Kinesis payload cannot be parsed as JSON", async () => { const handler = createHandler(mockedDeps); const invalidJsonPayload = "not valid json {{{"; @@ -191,11 +229,12 @@ describe("update-letter-queue Lambda", () => { describe("Metrics", () => { it("emits success metrics when all letters are processed successfully", async () => { const handler = createHandler(mockedDeps); - const newLetter1 = generateLetter("PENDING", "1"); + const oldLetter1 = generateLetter("PENDING", "1"); + const newLetter1 = generateLetter("ACCEPTED", "1"); const newLetter2 = generateLetter("PENDING", "2"); const testData = generateKinesisEvent([ - generateInsertRecord(newLetter1), + generateModifyRecord(oldLetter1, newLetter1), generateInsertRecord(newLetter2), ]); await handler(testData, mockDeep(), jest.fn()); @@ -204,7 +243,7 @@ describe("update-letter-queue Lambda", () => { assertFailureMetricLogged(0); }); - it("emits failure metrics when a letter fails to process", async () => { + it("emits failure metrics when a letter fails to be inserted", async () => { const handler = createHandler(mockedDeps); const newLetter1 = generateLetter("PENDING", "1"); const newLetter2 = generateLetter("PENDING", "2"); @@ -222,10 +261,31 @@ describe("update-letter-queue Lambda", () => { assertFailureMetricLogged(1); }); - it("does not count a reprocessed event as a success or failure", async () => { + it("emits failure metrics when a letter fails to be deleted", async () => { + const handler = createHandler(mockedDeps); + const oldLetter1 = generateLetter("PENDING", "1"); + const oldLetter2 = generateLetter("PENDING", "2"); + const newLetter1 = generateLetter("ACCEPTED", "1"); + const newLetter2 = generateLetter("ACCEPTED", "2"); + (mockedDeps.letterQueueRepository.deleteLetter as jest.Mock) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("DynamoDB error")); + + const testData = generateKinesisEvent([ + generateModifyRecord(oldLetter1, newLetter1), + generateModifyRecord(oldLetter2, newLetter2), + ]); + await handler(testData, mockDeep(), jest.fn()); + + assertSuccessMetricLogged(1); + assertFailureMetricLogged(1); + }); + + it("does not count a replayed insert as a success or failure", async () => { const handler = createHandler(mockedDeps); const newLetter1 = generateLetter("PENDING", "1"); const newLetter2 = generateLetter("PENDING", "2"); + (mockedDeps.letterQueueRepository.putLetter as jest.Mock) .mockRejectedValueOnce(new LetterAlreadyExistsError("supplier1", "1")) .mockResolvedValueOnce({}); @@ -240,6 +300,26 @@ describe("update-letter-queue Lambda", () => { assertFailureMetricLogged(0); }); + it("does not count a replayed delete as a success or failure", async () => { + const handler = createHandler(mockedDeps); + const oldLetter1 = generateLetter("PENDING", "1"); + const oldLetter2 = generateLetter("PENDING", "2"); + const newLetter1 = generateLetter("ACCEPTED", "1"); + const newLetter2 = generateLetter("ACCEPTED", "2"); + (mockedDeps.letterQueueRepository.deleteLetter as jest.Mock) + .mockRejectedValueOnce(new LetterDoesNotExistError("supplier1", "1")) + .mockResolvedValueOnce({}); + + const testData = generateKinesisEvent([ + generateModifyRecord(oldLetter1, newLetter1), + generateModifyRecord(oldLetter2, newLetter2), + ]); + await handler(testData, mockDeep(), jest.fn()); + + assertSuccessMetricLogged(1); + assertFailureMetricLogged(0); + }); + it("emits zero success metrics when no pending letters are in the batch", async () => { const handler = createHandler(mockedDeps); const newLetter = generateLetter("PRINTED"); diff --git a/lambdas/update-letter-queue/src/update-letter-queue.ts b/lambdas/update-letter-queue/src/update-letter-queue.ts index 5e1246241..392336fb2 100644 --- a/lambdas/update-letter-queue/src/update-letter-queue.ts +++ b/lambdas/update-letter-queue/src/update-letter-queue.ts @@ -11,6 +11,7 @@ import { InsertPendingLetter, Letter, LetterAlreadyExistsError, + LetterDoesNotExistError, LetterSchema, } from "@internal/datastore"; import { Deps } from "./deps"; @@ -28,49 +29,89 @@ export default function createHandler(deps: Deps): Handler { for (const record of streamEvent.Records) { const ddbRecord = extractPayload(record, deps); - if (isNewPendingLetter(ddbRecord)) { - const letter = extractNewLetter(ddbRecord); - const pendingLetter = mapLetterToPendingLetter(letter); - - try { - deps.logger.info({ - description: "Persisting pending letter", - pendingLetter, - }); - await deps.letterQueueRepository.putLetter(pendingLetter); - successCount += 1; - } catch (error) { - if (error instanceof LetterAlreadyExistsError) { - deps.logger.warn({ - description: "Letter already exists", - supplierId: pendingLetter.supplierId, - letterId: pendingLetter.letterId, - }); - } else { - deps.logger.error({ - description: "Error persisting pending letter", - error, - pendingLetter, - }); - recordProcessing(deps, successCount, 1); - // If we get a failure, return immediately without processing the remaining records. Since we are - // working with a Kinesis stream, AWS will retry from the point of failure and no records will be lost. - // See https://docs.aws.amazon.com/lambda/latest/dg/example_serverless_Kinesis_Lambda_batch_item_failures_section.html - return { - batchItemFailures: [ - { itemIdentifier: record.kinesis.sequenceNumber }, - ], - }; - } + try { + if (isNewPendingLetter(ddbRecord)) { + const added = await addPendingLetterToQueue(ddbRecord, deps); + successCount += added ? 1 : 0; + } else if (isNoLongerPending(ddbRecord)) { + const deleted = await deletePendingLetterFromQueue(ddbRecord, deps); + successCount += deleted ? 1 : 0; } + } catch (error) { + deps.logger.error({ + description: "Error processing ddbRecord", + error, + ddbRecord, + }); + recordProcessing(deps, successCount, 1); + // If we get a failure, return immediately without processing the remaining records. Since we are + // working with a Kinesis stream, AWS will retry from the point of failure and no records will be lost. + // See https://docs.aws.amazon.com/lambda/latest/dg/example_serverless_Kinesis_Lambda_batch_item_failures_section.html + return { + batchItemFailures: [ + { itemIdentifier: record.kinesis.sequenceNumber }, + ], + }; } } - recordProcessing(deps, successCount, 0); return { batchItemFailures: [] }; }; } +async function addPendingLetterToQueue( + ddbRecord: DynamoDBRecord, + deps: Deps, +): Promise { + const letter = extractNewLetter(ddbRecord); + const pendingLetter = mapLetterToPendingLetter(letter); + + try { + deps.logger.info({ + description: "Persisting pending letter", + pendingLetter, + }); + await deps.letterQueueRepository.putLetter(pendingLetter); + return true; + } catch (error) { + if (error instanceof LetterAlreadyExistsError) { + deps.logger.warn({ + description: "Letter already exists", + supplierId: pendingLetter.supplierId, + letterId: pendingLetter.letterId, + }); + return false; + } + throw error; + } +} + +async function deletePendingLetterFromQueue( + ddbRecord: DynamoDBRecord, + deps: Deps, +): Promise { + const letter = extractNewLetter(ddbRecord); + try { + deps.logger.info({ + description: "Deleting pending letter", + supplierId: letter.supplierId, + letterId: letter.id, + }); + await deps.letterQueueRepository.deleteLetter(letter.supplierId, letter.id); + return true; + } catch (error) { + if (error instanceof LetterDoesNotExistError) { + deps.logger.warn({ + description: "Letter does not exist", + supplierId: letter.supplierId, + letterId: letter.id, + }); + return false; + } + throw error; + } +} + function recordProcessing( deps: Deps, successCount: number, @@ -95,6 +136,15 @@ function isNewPendingLetter(record: DynamoDBRecord): boolean { return isInsert && isPending; } +function isNoLongerPending(record: DynamoDBRecord): boolean { + const isUpdate = record.eventName === "MODIFY"; + const oldImage = record.dynamodb?.OldImage; + const newImage = record.dynamodb?.NewImage; + const noLongerPending = + oldImage?.status?.S === "PENDING" && newImage?.status?.S !== "PENDING"; + return isUpdate && noLongerPending; +} + function extractPayload( record: KinesisStreamRecord, deps: Deps, From d754a693a08f414d3afe75bfef13638623498564 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Tue, 10 Mar 2026 16:00:59 +0000 Subject: [PATCH 2/8] Fix test --- .../src/__tests__/update-letter-queue.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts index c61acd83c..801b79175 100644 --- a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts +++ b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts @@ -185,7 +185,7 @@ describe("update-letter-queue Lambda", () => { const oldLetter2 = generateLetter("PENDING", "2"); const newLetter1 = generateLetter("ACCEPTED", "1"); const newLetter2 = generateLetter("ACCEPTED", "2"); - (mockedDeps.letterQueueRepository.putLetter as jest.Mock) + (mockedDeps.letterQueueRepository.deleteLetter as jest.Mock) .mockRejectedValueOnce(new LetterDoesNotExistError("supplier1", "1")) .mockResolvedValueOnce({}); From 3d4ae57baeb2b64ec76d9e069f07331e3e01eb4e Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 11 Mar 2026 11:43:49 +0000 Subject: [PATCH 3/8] Introduce visibility timeout --- .../terraform/components/api/README.md | 90 ------------------- .../components/api/ddb_table_letter_queue.tf | 2 +- .../api/module_lambda_update_letter_queue.tf | 6 +- internal/datastore/src/__test__/db.ts | 2 +- .../__test__/letter-queue-repository.test.ts | 24 +---- .../datastore/src/letter-queue-repository.ts | 7 +- internal/datastore/src/types.ts | 6 +- 7 files changed, 19 insertions(+), 118 deletions(-) delete mode 100644 infrastructure/terraform/components/api/README.md diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md deleted file mode 100644 index f85a3e5ee..000000000 --- a/infrastructure/terraform/components/api/README.md +++ /dev/null @@ -1,90 +0,0 @@ - - - - -## Requirements - -No requirements. -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [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 | -| [commit\_id](#input\_commit\_id) | The commit to deploy. Must be in the tree for branch\_name | `string` | `"HEAD"` | 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 | -| [disable\_gateway\_execute\_endpoint](#input\_disable\_gateway\_execute\_endpoint) | Disable the execution endpoint for the API Gateway | `bool` | `true` | no | -| [enable\_alarms](#input\_enable\_alarms) | Enable CloudWatch alarms for this deployed environment | `bool` | `true` | no | -| [enable\_api\_data\_trace](#input\_enable\_api\_data\_trace) | Enable API Gateway data trace logging | `bool` | `false` | no | -| [enable\_backups](#input\_enable\_backups) | Enable backups | `bool` | `false` | no | -| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for SNS message Detects abnormal drops or spikes in event publishing volume. | `bool` | `true` | no | -| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `true` | no | -| [enable\_sns\_delivery\_logging](#input\_enable\_sns\_delivery\_logging) | Enable SNS Delivery Failure Notifications | `bool` | `true` | no | -| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | -| [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `4` | no | -| [event\_anomaly\_evaluation\_periods](#input\_event\_anomaly\_evaluation\_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event\_anomaly\_period. | `number` | `3` | no | -| [event\_anomaly\_period](#input\_event\_anomaly\_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | -| [eventpub\_control\_plane\_bus\_arn](#input\_eventpub\_control\_plane\_bus\_arn) | ARN of the EventBridge control plane bus for eventpub | `string` | `""` | no | -| [eventpub\_data\_plane\_bus\_arn](#input\_eventpub\_data\_plane\_bus\_arn) | ARN of the EventBridge data plane bus for eventpub | `string` | `""` | no | -| [force\_destroy](#input\_force\_destroy) | Flag to force deletion of S3 buckets | `bool` | `false` | no | -| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | -| [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\_event\_source](#input\_letter\_event\_source) | Source value to use for the letter status event updates | `string` | `"/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status"` | 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 | -| [max\_get\_limit](#input\_max\_get\_limit) | Default limit to apply to GET requests that support pagination | `number` | `2500` | no | -| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | -| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | -| [region](#input\_region) | The AWS Region | `string` | n/a | yes | -| [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | -| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no | -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [amendment\_event\_transformer](#module\_amendment\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [amendments\_queue](#module\_amendments\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | -| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [ddb\_alarms\_letter\_queue](#module\_ddb\_alarms\_letter\_queue) | ../../modules/alarms-ddb | n/a | -| [ddb\_alarms\_letters](#module\_ddb\_alarms\_letters) | ../../modules/alarms-ddb | n/a | -| [ddb\_alarms\_mi](#module\_ddb\_alarms\_mi) | ../../modules/alarms-ddb | n/a | -| [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a | -| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | -| [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-eventpub.zip | n/a | -| [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a | -| [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a | -| [lambda\_alarms](#module\_lambda\_alarms) | ../../modules/alarms-lambda | n/a | -| [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | -| [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [mi\_updates\_transformer](#module\_mi\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | -| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a | -| [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a | -| [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | -| [sqs\_supplier\_allocator](#module\_sqs\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | -| [supplier\_allocator](#module\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-ssl.zip | n/a | -| [update\_letter\_queue](#module\_update\_letter\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [upsert\_letter](#module\_upsert\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -## Outputs - -| Name | Description | -|------|-------------| -| [api\_urll](#output\_api\_urll) | n/a | -| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | - - - diff --git a/infrastructure/terraform/components/api/ddb_table_letter_queue.tf b/infrastructure/terraform/components/api/ddb_table_letter_queue.tf index 19c3b2333..b6952ab46 100644 --- a/infrastructure/terraform/components/api/ddb_table_letter_queue.tf +++ b/infrastructure/terraform/components/api/ddb_table_letter_queue.tf @@ -11,7 +11,7 @@ resource "aws_dynamodb_table" "letter_queue" { } local_secondary_index { - name = "queueTimestamp-index" + name = "queueSortOrder-index" range_key = "queueTimestamp" projection_type = "ALL" } diff --git a/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf b/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf index 187f005c4..95862ff76 100644 --- a/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf +++ b/infrastructure/terraform/components/api/module_lambda_update_letter_queue.tf @@ -35,7 +35,7 @@ module "update_letter_queue" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - LETTER_QUEUE_TABLE_NAME = aws_dynamodb_table.letter_queue.name, + LETTER_QUEUE_TABLE_NAME = "${local.csi}-letter-queue", LETTER_QUEUE_TTL_HOURS = 168 # 7 days }) } @@ -51,8 +51,8 @@ data "aws_iam_policy_document" "update_letter_queue_lambda" { ] resources = [ - aws_dynamodb_table.letter_queue.arn, - "${aws_dynamodb_table.letter_queue.arn}/index/*" + "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${local.csi}-letter-queue", + "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${local.csi}-letter-queue/index/*" ] } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index f382add62..ae652ad13 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -129,7 +129,7 @@ const createLetterQueueTableCommand = new CreateTableCommand({ ], LocalSecondaryIndexes: [ { - IndexName: "timestamp-index", + IndexName: "queueSortOrder-index", KeySchema: [ { AttributeName: "supplierId", KeyType: "HASH" }, // Partition key for LSI { AttributeName: "queueTimestamp", KeyType: "RANGE" }, // Sort key for LSI diff --git a/internal/datastore/src/__test__/letter-queue-repository.test.ts b/internal/datastore/src/__test__/letter-queue-repository.test.ts index 0a12ca74a..2366892b6 100644 --- a/internal/datastore/src/__test__/letter-queue-repository.test.ts +++ b/internal/datastore/src/__test__/letter-queue-repository.test.ts @@ -53,32 +53,16 @@ describe("LetterQueueRepository", () => { await db.container.stop(); }); - function assertTtl(ttl: number, before: number, after: number) { - const expectedLower = Math.floor( - before / 1000 + 60 * 60 * db.config.letterQueueTtlHours, - ); - const expectedUpper = Math.floor( - after / 1000 + 60 * 60 * db.config.lettersTtlHours, - ); - expect(ttl).toBeGreaterThanOrEqual(expectedLower); - expect(ttl).toBeLessThanOrEqual(expectedUpper); - } - describe("putLetter", () => { it("adds a letter to the database", async () => { - const before = Date.now(); + jest.useFakeTimers().setSystemTime(new Date("2026-03-04T13:15:45.000Z")); const pendingLetter = await letterQueueRepository.putLetter(createLetter()); - const after = Date.now(); - - const timestampInMillis = new Date( - pendingLetter.queueTimestamp, - ).valueOf(); - expect(timestampInMillis).toBeGreaterThanOrEqual(before); - expect(timestampInMillis).toBeLessThanOrEqual(after); - assertTtl(pendingLetter.ttl, before, after); + expect(pendingLetter.queueTimestamp).toBe("2026-03-04T13:15:45.000Z"); + expect(pendingLetter.visibilityTimeout).toBe("2026-03-04T13:15:45.000Z"); + expect(pendingLetter.ttl).toBe(1_772_633_745); expect(await letterExists(db, "supplier1", "letter1")).toBe(true); }); diff --git a/internal/datastore/src/letter-queue-repository.ts b/internal/datastore/src/letter-queue-repository.ts index 6aecc3cda..ae2c019ac 100644 --- a/internal/datastore/src/letter-queue-repository.ts +++ b/internal/datastore/src/letter-queue-repository.ts @@ -27,10 +27,13 @@ export default class LetterQueueRepository { async putLetter( insertPendingLetter: InsertPendingLetter, ): Promise { + // needs to be an ISO timestamp as Db sorts alphabetically + const now = new Date().toISOString(); + const pendingLetter: PendingLetter = { ...insertPendingLetter, - // needs to be an ISO timestamp as Db sorts alphabetically - queueTimestamp: new Date().toISOString(), + queueTimestamp: now, + visibilityTimeout: now, ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.letterQueueTtlHours, ), diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index bb0843f82..c530fa03f 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -80,6 +80,7 @@ export const PendingLetterSchema = z.object({ supplierId: idRef(SupplierSchema, "id"), letterId: idRef(LetterSchema, "id"), queueTimestamp: z.string().describe("Secondary index SK"), + visibilityTimeout: z.string(), specificationId: z.string(), groupId: z.string(), ttl: z.int(), @@ -87,7 +88,10 @@ export const PendingLetterSchema = z.object({ export type PendingLetter = z.infer; -export type InsertPendingLetter = Omit; +export type InsertPendingLetter = Omit< + PendingLetter, + "ttl" | "queueTimestamp" | "visibilityTimeout" +>; export const MISchemaBase = z.object({ id: z.string(), From 6447e3a40a21bb115229833311666a11d27aef35 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 18 Mar 2026 13:26:18 +0000 Subject: [PATCH 4/8] Regenerate README.md --- .../terraform/components/api/README.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 infrastructure/terraform/components/api/README.md diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md new file mode 100644 index 000000000..f85a3e5ee --- /dev/null +++ b/infrastructure/terraform/components/api/README.md @@ -0,0 +1,90 @@ + + + + +## Requirements + +No requirements. +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [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 | +| [commit\_id](#input\_commit\_id) | The commit to deploy. Must be in the tree for branch\_name | `string` | `"HEAD"` | 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 | +| [disable\_gateway\_execute\_endpoint](#input\_disable\_gateway\_execute\_endpoint) | Disable the execution endpoint for the API Gateway | `bool` | `true` | no | +| [enable\_alarms](#input\_enable\_alarms) | Enable CloudWatch alarms for this deployed environment | `bool` | `true` | no | +| [enable\_api\_data\_trace](#input\_enable\_api\_data\_trace) | Enable API Gateway data trace logging | `bool` | `false` | no | +| [enable\_backups](#input\_enable\_backups) | Enable backups | `bool` | `false` | no | +| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for SNS message Detects abnormal drops or spikes in event publishing volume. | `bool` | `true` | no | +| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `true` | no | +| [enable\_sns\_delivery\_logging](#input\_enable\_sns\_delivery\_logging) | Enable SNS Delivery Failure Notifications | `bool` | `true` | no | +| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `4` | no | +| [event\_anomaly\_evaluation\_periods](#input\_event\_anomaly\_evaluation\_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event\_anomaly\_period. | `number` | `3` | no | +| [event\_anomaly\_period](#input\_event\_anomaly\_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | +| [eventpub\_control\_plane\_bus\_arn](#input\_eventpub\_control\_plane\_bus\_arn) | ARN of the EventBridge control plane bus for eventpub | `string` | `""` | no | +| [eventpub\_data\_plane\_bus\_arn](#input\_eventpub\_data\_plane\_bus\_arn) | ARN of the EventBridge data plane bus for eventpub | `string` | `""` | no | +| [force\_destroy](#input\_force\_destroy) | Flag to force deletion of S3 buckets | `bool` | `false` | no | +| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | +| [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\_event\_source](#input\_letter\_event\_source) | Source value to use for the letter status event updates | `string` | `"/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status"` | 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 | +| [max\_get\_limit](#input\_max\_get\_limit) | Default limit to apply to GET requests that support pagination | `number` | `2500` | no | +| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | +| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [region](#input\_region) | The AWS Region | `string` | n/a | yes | +| [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | +| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no | +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [amendment\_event\_transformer](#module\_amendment\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [amendments\_queue](#module\_amendments\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | +| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [ddb\_alarms\_letter\_queue](#module\_ddb\_alarms\_letter\_queue) | ../../modules/alarms-ddb | n/a | +| [ddb\_alarms\_letters](#module\_ddb\_alarms\_letters) | ../../modules/alarms-ddb | n/a | +| [ddb\_alarms\_mi](#module\_ddb\_alarms\_mi) | ../../modules/alarms-ddb | n/a | +| [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a | +| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | +| [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-eventpub.zip | n/a | +| [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a | +| [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a | +| [lambda\_alarms](#module\_lambda\_alarms) | ../../modules/alarms-lambda | n/a | +| [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | +| [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [mi\_updates\_transformer](#module\_mi\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | +| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a | +| [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a | +| [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | +| [sqs\_supplier\_allocator](#module\_sqs\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | +| [supplier\_allocator](#module\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-ssl.zip | n/a | +| [update\_letter\_queue](#module\_update\_letter\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [upsert\_letter](#module\_upsert\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +## Outputs + +| Name | Description | +|------|-------------| +| [api\_urll](#output\_api\_urll) | n/a | +| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | + + + From b4c5f0bfff483210b79d8256ec54e9bb7266c5cc Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 11 Mar 2026 14:47:22 +0000 Subject: [PATCH 5/8] Rename visibilityTimeout to visibilityTimestamp --- .../datastore/src/__test__/letter-queue-repository.test.ts | 2 +- internal/datastore/src/letter-queue-repository.ts | 2 +- internal/datastore/src/types.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/datastore/src/__test__/letter-queue-repository.test.ts b/internal/datastore/src/__test__/letter-queue-repository.test.ts index 2366892b6..b2f4e571e 100644 --- a/internal/datastore/src/__test__/letter-queue-repository.test.ts +++ b/internal/datastore/src/__test__/letter-queue-repository.test.ts @@ -61,7 +61,7 @@ describe("LetterQueueRepository", () => { await letterQueueRepository.putLetter(createLetter()); expect(pendingLetter.queueTimestamp).toBe("2026-03-04T13:15:45.000Z"); - expect(pendingLetter.visibilityTimeout).toBe("2026-03-04T13:15:45.000Z"); + expect(pendingLetter.visibilityTimestamp).toBe("2026-03-04T13:15:45.000Z"); expect(pendingLetter.ttl).toBe(1_772_633_745); expect(await letterExists(db, "supplier1", "letter1")).toBe(true); }); diff --git a/internal/datastore/src/letter-queue-repository.ts b/internal/datastore/src/letter-queue-repository.ts index ae2c019ac..70592db25 100644 --- a/internal/datastore/src/letter-queue-repository.ts +++ b/internal/datastore/src/letter-queue-repository.ts @@ -33,7 +33,7 @@ export default class LetterQueueRepository { const pendingLetter: PendingLetter = { ...insertPendingLetter, queueTimestamp: now, - visibilityTimeout: now, + visibilityTimestamp: now, ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.letterQueueTtlHours, ), diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index c530fa03f..107a6c8b8 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -80,7 +80,7 @@ export const PendingLetterSchema = z.object({ supplierId: idRef(SupplierSchema, "id"), letterId: idRef(LetterSchema, "id"), queueTimestamp: z.string().describe("Secondary index SK"), - visibilityTimeout: z.string(), + visibilityTimestamp: z.string(), specificationId: z.string(), groupId: z.string(), ttl: z.int(), @@ -90,7 +90,7 @@ export type PendingLetter = z.infer; export type InsertPendingLetter = Omit< PendingLetter, - "ttl" | "queueTimestamp" | "visibilityTimeout" + "ttl" | "queueTimestamp" | "visibilityTimestamp" >; export const MISchemaBase = z.object({ From a0669a6277f8640fd6677986a4609d97588d0b1a Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 11 Mar 2026 15:08:42 +0000 Subject: [PATCH 6/8] Linting fix --- .../datastore/src/__test__/letter-queue-repository.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/datastore/src/__test__/letter-queue-repository.test.ts b/internal/datastore/src/__test__/letter-queue-repository.test.ts index b2f4e571e..04e8d57ca 100644 --- a/internal/datastore/src/__test__/letter-queue-repository.test.ts +++ b/internal/datastore/src/__test__/letter-queue-repository.test.ts @@ -61,7 +61,9 @@ describe("LetterQueueRepository", () => { await letterQueueRepository.putLetter(createLetter()); expect(pendingLetter.queueTimestamp).toBe("2026-03-04T13:15:45.000Z"); - expect(pendingLetter.visibilityTimestamp).toBe("2026-03-04T13:15:45.000Z"); + expect(pendingLetter.visibilityTimestamp).toBe( + "2026-03-04T13:15:45.000Z", + ); expect(pendingLetter.ttl).toBe(1_772_633_745); expect(await letterExists(db, "supplier1", "letter1")).toBe(true); }); From 80e312df413559b41ee31586e50e2f8d71f6bf94 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Tue, 17 Mar 2026 13:36:58 +0000 Subject: [PATCH 7/8] Fix trivy vulnerabilities --- package-lock.json | 12 ++++++------ package.json | 2 ++ tests/e2e-tests/poetry.lock | 10 +++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44b6fe221..8ac033f60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13543,9 +13543,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -21883,9 +21883,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 7bf1c6b41..8ec3fd890 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "axios": "^1.13.5", "fast-xml-parser": "^5.3.6", "@isaacs/brace-expansion": "^5.0.1", + "flatted": "^3.4.0", + "undici": "^7.24.0", "pretty-format": { "react-is": "19.0.0" }, diff --git a/tests/e2e-tests/poetry.lock b/tests/e2e-tests/poetry.lock index a1f09a321..498e37c36 100644 --- a/tests/e2e-tests/poetry.lock +++ b/tests/e2e-tests/poetry.lock @@ -863,21 +863,21 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, + {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, ] [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pyotp" From 0a5447178beefb8d278537009e4f800971d1ff6c Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 18 Mar 2026 11:27:40 +0000 Subject: [PATCH 8/8] Fix fast-xml-parser too --- package-lock.json | 35 +++++++++++++++++++++++++++-------- package.json | 2 +- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ac033f60..f206f991a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13336,21 +13336,24 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "funding": [ { "type": "github", @@ -13359,7 +13362,8 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -18326,6 +18330,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", diff --git a/package.json b/package.json index 8ec3fd890..d1cd3f780 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "name": "nhs-notify-supplier-api", "overrides": { "axios": "^1.13.5", - "fast-xml-parser": "^5.3.6", + "fast-xml-parser": "^5.5.6", "@isaacs/brace-expansion": "^5.0.1", "flatted": "^3.4.0", "undici": "^7.24.0",