From cf2400ec6480447f43673f4022c4440fb9f77688 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:14:27 +0100 Subject: [PATCH 1/6] CCM-23784: Add an update-stale-pending-letters script --- .../src/__test__/letter-repository.test.ts | 299 ++++++++++++- internal/datastore/src/letter-repository.ts | 73 +++- package-lock.json | 403 ++++++++++++------ package.json | 1 + .../update-stale-pending-letters/.gitignore | 4 + .../update-stale-pending-letters/package.json | 19 + .../update-stale-pending-letters/src/index.ts | 128 ++++++ .../tsconfig.json | 7 + 8 files changed, 798 insertions(+), 136 deletions(-) create mode 100644 scripts/maintenance/update-stale-pending-letters/.gitignore create mode 100644 scripts/maintenance/update-stale-pending-letters/package.json create mode 100644 scripts/maintenance/update-stale-pending-letters/src/index.ts create mode 100644 scripts/maintenance/update-stale-pending-letters/tsconfig.json diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 724b5e609..937c564a2 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -9,19 +9,21 @@ import { LetterRepository } from "../letter-repository"; import { InsertLetter, Letter, UpdateLetter } from "../types"; import { createTestLogger } from "./logs"; import LetterAlreadyExistsError from "../errors/letter-already-exists-error"; +import LetterNotFoundError from "../errors/letter-not-found-error"; function createLetter( supplierId: string, letterId: string, status: Letter["status"] = "PENDING", eventId?: string, + specificationId = "specification1", ): InsertLetter { const now = new Date().toISOString(); return { id: letterId, eventId, supplierId, - specificationId: "specification1", + specificationId, groupId: "group1", url: `s3://bucket/${letterId}.pdf`, status, @@ -39,6 +41,14 @@ function assertDateBetween(date: number, before: number, after: number) { expect(date).toBeLessThanOrEqual(after); } +async function collect(generator: AsyncGenerator): Promise { + const results: Letter[] = []; + for await (const letter of generator) { + results.push(letter); + } + return results; +} + // Database tests can take longer, especially with setup and teardown jest.setTimeout(30_000); @@ -367,4 +377,291 @@ describe("LetterRepository", () => { ]), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); + + describe("queryLettersBySupplierStatus", () => { + test("returns letters within the supplierStatusSk range and matching specificationId", async () => { + jest.useFakeTimers(); + + jest.setSystemTime(new Date("2026-09-01T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "before-range", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + + jest.setSystemTime(new Date("2026-09-03T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "in-range", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + jest.setSystemTime(new Date("2026-09-04T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "in-range2", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + await letterRepository.putLetter( + createLetter( + "xerox", + "in-range-wrong-spec", + "PENDING", + undefined, + "other-spec", + ), + ); + await letterRepository.putLetter( + createLetter( + "other-supplier", + "in-range-wrong-supplier", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + await letterRepository.putLetter( + createLetter( + "xerox", + "in-range-wrong-status", + "ACCEPTED", + undefined, + "digitrials-ofh", + ), + ); + + jest.setSystemTime(new Date("2026-09-06T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "after-range", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-09-02", + "2026-09-05", + "digitrials-ofh", + ), + ); + + expect(results.map((letter) => letter.id)).toEqual([ + "in-range", + "in-range2", + ]); + }); + + test("treats an undefined result.Items as no matches, without throwing", async () => { + (jest.spyOn(db.docClient, "send") as jest.Mock).mockResolvedValueOnce({ + $metadata: {}, + Items: undefined, + }); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-09-02", + "2026-09-05", + "digitrials-ofh", + ), + ); + + expect(results).toEqual([]); + }); + + test("includes a letter whose supplierStatusSk exactly equals startDate", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "at-start", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-01-01T00:00:00.000Z", + "2026-01-05T00:00:00.000Z", + "digitrials-ofh", + ), + ); + + expect(results.map((letter) => letter.id)).toEqual(["at-start"]); + }); + + test("includes a letter whose supplierStatusSk exactly equals endDate", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-05T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter("xerox", "at-end", "PENDING", undefined, "digitrials-ofh"), + ); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-01-01T00:00:00.000Z", + "2026-01-05T00:00:00.000Z", + "digitrials-ofh", + ), + ); + + expect(results.map((letter) => letter.id)).toEqual(["at-end"]); + }); + + test("excludes a letter one millisecond before startDate", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "just-before-start", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-01-01T00:00:00.001Z", + "2026-01-05T00:00:00.000Z", + "digitrials-ofh", + ), + ); + + expect(results).toEqual([]); + }); + + test("excludes a letter one millisecond after endDate", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-05T00:00:00.001Z")); + await letterRepository.putLetter( + createLetter( + "xerox", + "just-after-end", + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + + const results = await collect( + letterRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-01-01T00:00:00.000Z", + "2026-01-05T00:00:00.000Z", + "digitrials-ofh", + ), + ); + + expect(results).toEqual([]); + }); + + test("paginates across multiple pages", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-09-03T00:00:00.000Z")); + + for (let i = 0; i < 5; i++) { + await letterRepository.putLetter( + createLetter( + "xerox", + `letter${i}`, + "PENDING", + undefined, + "digitrials-ofh", + ), + ); + } + + const pagedRepository = new LetterRepository(db.docClient, logger, { + ...db.config, + queryPageSize: 2, + }); + + const results = await collect( + pagedRepository.queryLettersBySupplierStatus( + "xerox", + "PENDING", + "2026-09-02", + "2026-09-05", + "digitrials-ofh", + ), + ); + + expect(results).toHaveLength(5); + }); + }); + + describe("touchLetter", () => { + test("updates only the updatedAt field", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-09-01T00:00:00.000Z")); + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + const original = await letterRepository.getLetterById( + "supplier1", + "letter1", + ); + + jest.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + await letterRepository.touchLetter("supplier1", "letter1"); + + const touched = await letterRepository.getLetterById( + "supplier1", + "letter1", + ); + expect(touched.updatedAt).toBe("2026-09-02T00:00:00.000Z"); + expect(touched.status).toBe(original.status); + expect(touched.ttl).toBe(original.ttl); + expect(touched.supplierStatus).toBe(original.supplierStatus); + expect(touched.supplierStatusSk).toBe(original.supplierStatusSk); + }); + + test("throws LetterNotFoundError when the letter does not exist", async () => { + await expect( + letterRepository.touchLetter("supplier1", "missing-letter"), + ).rejects.toThrow(LetterNotFoundError); + }); + + test("rethrows errors from DynamoDB", async () => { + const misconfiguredRepository = new LetterRepository( + db.docClient, + logger, + { + ...db.config, + lettersTableName: "nonexistent-table", + }, + ); + await expect( + misconfiguredRepository.touchLetter("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 2c1e8c60c..1a6e4f81c 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -3,12 +3,20 @@ import { DynamoDBDocumentClient, GetCommand, PutCommand, + QueryCommand, UpdateCommand, UpdateCommandOutput, } from "@aws-sdk/lib-dynamodb"; import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"; import { Logger } from "pino"; -import { InsertLetter, Letter, LetterSchema, UpdateLetter } from "./types"; +import z from "zod"; +import { + InsertLetter, + Letter, + LetterSchema, + LetterStatusType, + UpdateLetter, +} from "./types"; import LetterNotFoundError from "./errors/letter-not-found-error"; import LetterAlreadyExistsError from "./errors/letter-already-exists-error"; @@ -20,6 +28,8 @@ export type PagingOptions = Partial<{ export type LetterRepositoryConfig = { lettersTableName: string; lettersTtlHours: number; + /** Maximum number of items to fetch per DynamoDB page. Defaults to 1000. */ + queryPageSize?: number; }; export class LetterRepository { @@ -109,6 +119,67 @@ export class LetterRepository { return LetterSchema.parse(result.Item); } + /** Streams letters via the supplierStatus-index GSI, filtered by supplierStatusSk range and specificationId. */ + async *queryLettersBySupplierStatus( + supplierId: string, + status: LetterStatusType, + startDate: string, + endDate: string, + specificationId: string, + ): AsyncGenerator { + let lastEvaluatedKey: Record | undefined; + + do { + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.lettersTableName, + IndexName: "supplierStatus-index", + KeyConditionExpression: + "supplierStatus = :supplierStatus AND supplierStatusSk BETWEEN :startDate AND :endDate", + FilterExpression: "specificationId = :specificationId", + ExpressionAttributeValues: { + ":supplierStatus": `${supplierId}#${status}`, + ":startDate": startDate, + ":endDate": endDate, + ":specificationId": specificationId, + }, + Limit: this.config.queryPageSize ?? 1000, + ExclusiveStartKey: lastEvaluatedKey, + }), + ); + + const page = z.array(LetterSchema).parse(result.Items ?? []); + yield* page; + + lastEvaluatedKey = result.LastEvaluatedKey; + } while (lastEvaluatedKey !== undefined); + } + + /** Updates only the updatedAt timestamp, without touching status, ttl or supplierStatus. */ + async touchLetter(supplierId: string, letterId: string): Promise { + try { + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.lettersTableName, + Key: { + id: letterId, + supplierId, + }, + UpdateExpression: "SET updatedAt = :updatedAt", + ConditionExpression: "attribute_exists(id)", + ExpressionAttributeValues: { + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + throw new LetterNotFoundError(supplierId, letterId); + } + throw error; + } + } + async updateLetterStatus( letterToUpdate: UpdateLetter, ): Promise { diff --git a/package-lock.json b/package-lock.json index 3d06325f6..dff8dc673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "internal/*", "lambdas/*", "pact-contracts", + "scripts/maintenance/*", "scripts/utilities/*", "tests", "tests/contracts/*" @@ -1953,17 +1954,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", - "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.31.1", + "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -1981,15 +1982,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", - "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -1997,17 +1998,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", - "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2015,23 +2016,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", - "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/credential-provider-env": "^3.972.67", - "@aws-sdk/credential-provider-http": "^3.972.69", - "@aws-sdk/credential-provider-login": "^3.972.74", - "@aws-sdk/credential-provider-process": "^3.972.67", - "@aws-sdk/credential-provider-sso": "^3.973.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.73", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2039,16 +2040,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", - "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2056,21 +2057,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.78", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", - "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.67", - "@aws-sdk/credential-provider-http": "^3.972.69", - "@aws-sdk/credential-provider-ini": "^3.973.12", - "@aws-sdk/credential-provider-process": "^3.972.67", - "@aws-sdk/credential-provider-sso": "^3.973.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.73", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2078,15 +2079,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", - "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2094,17 +2095,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", - "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/token-providers": "3.1103.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2112,16 +2113,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", - "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2244,18 +2245,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", - "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/signature-v4-multi-region": "^3.996.43", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2263,14 +2264,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", - "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2278,16 +2279,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1103.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", - "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2295,12 +2296,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2336,12 +2337,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6659,12 +6660,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.31.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", - "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -6672,13 +6673,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", - "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6686,13 +6687,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", - "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -6713,13 +6714,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", - "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -6741,9 +6742,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18087,6 +18088,10 @@ "resolved": "lambdas/update-letter-queue", "link": true }, + "node_modules/nhs-notify-supplier-api-update-stale-pending-letters": { + "resolved": "scripts/maintenance/update-stale-pending-letters", + "link": true + }, "node_modules/nhs-notify-supplier-api-upsert-letter": { "resolved": "lambdas/upsert-letter", "link": true @@ -23070,6 +23075,136 @@ "version": "1.0.1", "license": "MIT" }, + "scripts/maintenance/update-stale-pending-letters": { + "name": "nhs-notify-supplier-api-update-stale-pending-letters", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-dynamodb": "3.1107.0", + "@aws-sdk/client-sts": "3.1107.0", + "@aws-sdk/lib-dynamodb": "3.1107.0", + "@internal/datastore": "*", + "pino": "^10.3.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/client-dynamodb": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1107.0.tgz", + "integrity": "sha512-MEo8N0ZtvNEQdiovlrUJdvUMfQTkDP98eOkRqSbY98p5/GTpBoENOzAa1G3bjfOs+lcEck80+0U7HuGSeErwZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/dynamodb-codec": "^3.973.41", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.27", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/client-sts": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1107.0.tgz", + "integrity": "sha512-9Rf4GpBDvNM0Fomv+uuGsX9YnZlxBOpbPamD8A4xAOZQP9v9GeDccXU/XEt84k/cWwR9Orol5ON1aId5vSRiiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.973.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.45.tgz", + "integrity": "sha512-WMnbfe3igJy14RRzEnLDW9G2V0WbrXdnp2UYAGYJIgbB1wR33bBle0dmbVnejs6sHJWfP2N1cAAx/QxXnoyK8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz", + "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==", + "license": "Apache-2.0", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/lib-dynamodb": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1107.0.tgz", + "integrity": "sha512-mVq9UrlSxH8PuvzSfCi2OsUYRk4o+0QGwR76areFXZnHB5KQHxE63WTXtQNBPhUmC/GwGwK6VZYbpwOtOCLLFA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/util-dynamodb": "^3.996.7", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1107.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/lib-dynamodb/node_modules/@aws-sdk/util-dynamodb": { + "version": "3.996.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz", + "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1111.0" + } + }, + "scripts/maintenance/update-stale-pending-letters/node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.30.tgz", + "integrity": "sha512-0hmD7/NG2NoVOVHvUb6rtY5POQYr+/9gdHvrYhIBA1zmCqKOBd5Gz3dL+iZ15FHOJbs/5kLplGoePc8PHTlzYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.11", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "scripts/utilities/letter-test-data": { "name": "nhs-notify-supplier-api-letter-test-data-utility", "version": "0.0.1", diff --git a/package.json b/package.json index fcabe24f9..5e3c1c563 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "internal/*", "lambdas/*", "pact-contracts", + "scripts/maintenance/*", "scripts/utilities/*", "tests", "tests/contracts/*" diff --git a/scripts/maintenance/update-stale-pending-letters/.gitignore b/scripts/maintenance/update-stale-pending-letters/.gitignore new file mode 100644 index 000000000..80323f7cf --- /dev/null +++ b/scripts/maintenance/update-stale-pending-letters/.gitignore @@ -0,0 +1,4 @@ +coverage +node_modules +dist +.reports diff --git a/scripts/maintenance/update-stale-pending-letters/package.json b/scripts/maintenance/update-stale-pending-letters/package.json new file mode 100644 index 000000000..494a7e5fb --- /dev/null +++ b/scripts/maintenance/update-stale-pending-letters/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "@aws-sdk/client-dynamodb": "3.1107.0", + "@aws-sdk/client-sts": "3.1107.0", + "@aws-sdk/lib-dynamodb": "3.1107.0", + "@internal/datastore": "*", + "pino": "^10.3.0" + }, + "name": "nhs-notify-supplier-api-update-stale-pending-letters", + "private": true, + "scripts": { + "cli": "tsx ./src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "echo \"Update stale pending letters script has no unit tests\"", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/scripts/maintenance/update-stale-pending-letters/src/index.ts b/scripts/maintenance/update-stale-pending-letters/src/index.ts new file mode 100644 index 000000000..72dabfe26 --- /dev/null +++ b/scripts/maintenance/update-stale-pending-letters/src/index.ts @@ -0,0 +1,128 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; +import { Logger, pino } from "pino"; +import { LetterRepository } from "@internal/datastore"; + +// --- Hardcoded parameters for this one-off run: change these values directly rather than passing them as args --- +const TABLE_NAME = "nhs-main-supapi-letters"; +const SUPPLIER_ID = "xerox"; +const STATUS = "PENDING"; +const START_DATE = "2026-09-02"; +const END_DATE = "2026-09-05"; +const SPECIFICATION_ID = "digitrials-ofh"; +const CONCURRENCY = 5; +const LETTERS_TTL_HOURS = 12_960; // unused by touchLetter, required by LetterRepositoryConfig + +function parseDryRunArg(): boolean { + const arg = process.argv.find((value) => value.startsWith("--dry-run=")); + if (!arg) { + return true; + } + return arg.split("=")[1] !== "false"; +} + +async function logTargetAccount(logger: Logger, dryRun: boolean) { + const stsClient = new STSClient({}); + const identity = await stsClient.send(new GetCallerIdentityCommand({})); + + logger.info({ + description: "Target run configuration", + account: identity.Account, + arn: identity.Arn, + region: await stsClient.config.region(), + tableName: TABLE_NAME, + dryRun, + }); +} + +async function main() { + const logger = pino(); + const dryRun = parseDryRunArg(); + + await logTargetAccount(logger, dryRun); + + const ddbClient = new DynamoDBClient({}); + const docClient = DynamoDBDocumentClient.from(ddbClient); + const letterRepo = new LetterRepository(docClient, logger, { + lettersTableName: TABLE_NAME, + lettersTtlHours: LETTERS_TTL_HOURS, + }); + + let matchedCount = 0; + let updatedCount = 0; + let errorCount = 0; + + async function processLetter(letter: { id: string; supplierId: string }) { + if (dryRun) { + logger.info({ + description: "DRY RUN — would update letter", + id: letter.id, + supplierId: letter.supplierId, + }); + return; + } + + try { + await letterRepo.touchLetter(letter.supplierId, letter.id); + updatedCount += 1; + logger.info({ + description: "Updated letter", + id: letter.id, + supplierId: letter.supplierId, + }); + } catch (error) { + errorCount += 1; + logger.error({ + description: "Failed to update letter", + id: letter.id, + supplierId: letter.supplierId, + err: error, + }); + } + + if ((updatedCount + errorCount) % 100 === 0) { + logger.info({ + description: "Progress", + matchedCount, + updatedCount, + errorCount, + }); + } + } + + const matches = letterRepo.queryLettersBySupplierStatus( + SUPPLIER_ID, + STATUS, + START_DATE, + END_DATE, + SPECIFICATION_ID, + ); + + let batch = []; + for await (const letter of matches) { + matchedCount += 1; + batch.push(letter); + + if (batch.length === CONCURRENCY) { + await Promise.all(batch.map((item) => processLetter(item))); + batch = []; + } + } + if (batch.length > 0) { + await Promise.all(batch.map((item) => processLetter(item))); + } + + logger.info({ + description: dryRun ? "DRY RUN complete" : "Run complete", + matchedCount, + updatedCount, + errorCount, + }); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/maintenance/update-stale-pending-letters/tsconfig.json b/scripts/maintenance/update-stale-pending-letters/tsconfig.json new file mode 100644 index 000000000..b89b97897 --- /dev/null +++ b/scripts/maintenance/update-stale-pending-letters/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": {}, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} From 70d6e20f566d266462e9b587a1d5aa62d320172c Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:38:25 +0100 Subject: [PATCH 2/6] CCM-23784: Save updated/failed letter details to a file --- .../update-stale-pending-letters/.gitignore | 1 + .../update-stale-pending-letters/src/index.ts | 51 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/scripts/maintenance/update-stale-pending-letters/.gitignore b/scripts/maintenance/update-stale-pending-letters/.gitignore index 80323f7cf..59e32b2dd 100644 --- a/scripts/maintenance/update-stale-pending-letters/.gitignore +++ b/scripts/maintenance/update-stale-pending-letters/.gitignore @@ -2,3 +2,4 @@ coverage node_modules dist .reports +output diff --git a/scripts/maintenance/update-stale-pending-letters/src/index.ts b/scripts/maintenance/update-stale-pending-letters/src/index.ts index 72dabfe26..863dad623 100644 --- a/scripts/maintenance/update-stale-pending-letters/src/index.ts +++ b/scripts/maintenance/update-stale-pending-letters/src/index.ts @@ -1,3 +1,6 @@ +import { WriteStream, createWriteStream, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; @@ -14,6 +17,22 @@ const SPECIFICATION_ID = "digitrials-ofh"; const CONCURRENCY = 5; const LETTERS_TTL_HOURS = 12_960; // unused by touchLetter, required by LetterRepositoryConfig +const OUTPUT_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "output", +); + +function outputFilePaths(timestamp: string) { + return { + updatedIdsFile: path.join( + OUTPUT_DIR, + `updated-letter-ids-${timestamp}.txt`, + ), + failedIdsFile: path.join(OUTPUT_DIR, `failed-letter-ids-${timestamp}.txt`), + }; +} + function parseDryRunArg(): boolean { const arg = process.argv.find((value) => value.startsWith("--dry-run=")); if (!arg) { @@ -49,30 +68,29 @@ async function main() { lettersTtlHours: LETTERS_TTL_HOURS, }); + mkdirSync(OUTPUT_DIR, { recursive: true }); + const { failedIdsFile, updatedIdsFile } = outputFilePaths( + new Date().toISOString().replaceAll(/[:.]/g, "-"), + ); + const updatedIdsStream = createWriteStream(updatedIdsFile); + const failedIdsStream = createWriteStream(failedIdsFile); + let matchedCount = 0; let updatedCount = 0; let errorCount = 0; async function processLetter(letter: { id: string; supplierId: string }) { if (dryRun) { - logger.info({ - description: "DRY RUN — would update letter", - id: letter.id, - supplierId: letter.supplierId, - }); return; } try { await letterRepo.touchLetter(letter.supplierId, letter.id); updatedCount += 1; - logger.info({ - description: "Updated letter", - id: letter.id, - supplierId: letter.supplierId, - }); + updatedIdsStream.write(`${letter.id}\n`); } catch (error) { errorCount += 1; + failedIdsStream.write(`${letter.id}\n`); logger.error({ description: "Failed to update letter", id: letter.id, @@ -113,11 +131,24 @@ async function main() { await Promise.all(batch.map((item) => processLetter(item))); } + await closeStream(updatedIdsStream); + await closeStream(failedIdsStream); + logger.info({ description: dryRun ? "DRY RUN complete" : "Run complete", matchedCount, updatedCount, errorCount, + updatedIdsFile, + failedIdsFile, + }); +} + +function closeStream(stream: WriteStream): Promise { + return new Promise((resolve, reject) => { + stream.end((error: Error | null | undefined) => + error ? reject(error) : resolve(), + ); }); } From ad13b82cc5e1ec23a79ce21dd0600970f4f49a63 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:41:20 +0100 Subject: [PATCH 3/6] CCM-23784: Add a delay after logging target account --- .../maintenance/update-stale-pending-letters/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/maintenance/update-stale-pending-letters/src/index.ts b/scripts/maintenance/update-stale-pending-letters/src/index.ts index 863dad623..c3901ab67 100644 --- a/scripts/maintenance/update-stale-pending-letters/src/index.ts +++ b/scripts/maintenance/update-stale-pending-letters/src/index.ts @@ -53,6 +53,11 @@ async function logTargetAccount(logger: Logger, dryRun: boolean) { tableName: TABLE_NAME, dryRun, }); + + // Give the operator a window to abort if the logged account/table is wrong + await new Promise((resolve) => { + setTimeout(resolve, 5000); + }); } async function main() { From a5aa2a20993fc274781df2f54f4806beaffba308 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:19:21 +0100 Subject: [PATCH 4/6] CCM-23784: Make test values generic --- .../src/__test__/letter-repository.test.ts | 76 ++++++++++--------- .../update-stale-pending-letters/src/index.ts | 4 +- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 937c564a2..bf6270cab 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -385,37 +385,37 @@ describe("LetterRepository", () => { jest.setSystemTime(new Date("2026-09-01T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "before-range", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); jest.setSystemTime(new Date("2026-09-03T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "in-range", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); jest.setSystemTime(new Date("2026-09-04T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "in-range2", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "in-range-wrong-spec", "PENDING", undefined, @@ -428,37 +428,37 @@ describe("LetterRepository", () => { "in-range-wrong-supplier", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "in-range-wrong-status", "ACCEPTED", undefined, - "digitrials-ofh", + "specification", ), ); jest.setSystemTime(new Date("2026-09-06T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "after-range", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-09-02", "2026-09-05", - "digitrials-ofh", + "specification", ), ); @@ -476,11 +476,11 @@ describe("LetterRepository", () => { const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-09-02", "2026-09-05", - "digitrials-ofh", + "specification", ), ); @@ -492,21 +492,21 @@ describe("LetterRepository", () => { jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "at-start", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-01-01T00:00:00.000Z", "2026-01-05T00:00:00.000Z", - "digitrials-ofh", + "specification", ), ); @@ -517,16 +517,22 @@ describe("LetterRepository", () => { jest.useFakeTimers(); jest.setSystemTime(new Date("2026-01-05T00:00:00.000Z")); await letterRepository.putLetter( - createLetter("xerox", "at-end", "PENDING", undefined, "digitrials-ofh"), + createLetter( + "supplier", + "at-end", + "PENDING", + undefined, + "specification", + ), ); const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-01-01T00:00:00.000Z", "2026-01-05T00:00:00.000Z", - "digitrials-ofh", + "specification", ), ); @@ -538,21 +544,21 @@ describe("LetterRepository", () => { jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "just-before-start", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-01-01T00:00:00.001Z", "2026-01-05T00:00:00.000Z", - "digitrials-ofh", + "specification", ), ); @@ -564,21 +570,21 @@ describe("LetterRepository", () => { jest.setSystemTime(new Date("2026-01-05T00:00:00.001Z")); await letterRepository.putLetter( createLetter( - "xerox", + "supplier", "just-after-end", "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); const results = await collect( letterRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-01-01T00:00:00.000Z", "2026-01-05T00:00:00.000Z", - "digitrials-ofh", + "specification", ), ); @@ -592,11 +598,11 @@ describe("LetterRepository", () => { for (let i = 0; i < 5; i++) { await letterRepository.putLetter( createLetter( - "xerox", + "supplier", `letter${i}`, "PENDING", undefined, - "digitrials-ofh", + "specification", ), ); } @@ -608,11 +614,11 @@ describe("LetterRepository", () => { const results = await collect( pagedRepository.queryLettersBySupplierStatus( - "xerox", + "supplier", "PENDING", "2026-09-02", "2026-09-05", - "digitrials-ofh", + "specification", ), ); diff --git a/scripts/maintenance/update-stale-pending-letters/src/index.ts b/scripts/maintenance/update-stale-pending-letters/src/index.ts index c3901ab67..2aa76c76e 100644 --- a/scripts/maintenance/update-stale-pending-letters/src/index.ts +++ b/scripts/maintenance/update-stale-pending-letters/src/index.ts @@ -9,11 +9,11 @@ import { LetterRepository } from "@internal/datastore"; // --- Hardcoded parameters for this one-off run: change these values directly rather than passing them as args --- const TABLE_NAME = "nhs-main-supapi-letters"; -const SUPPLIER_ID = "xerox"; +const SUPPLIER_ID = "supplier-placeholder"; const STATUS = "PENDING"; const START_DATE = "2026-09-02"; const END_DATE = "2026-09-05"; -const SPECIFICATION_ID = "digitrials-ofh"; +const SPECIFICATION_ID = "specification-placeholder"; const CONCURRENCY = 5; const LETTERS_TTL_HOURS = 12_960; // unused by touchLetter, required by LetterRepositoryConfig From 8fc32e9ce3b66b73120daf7dadfd01309a448cea Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:50 +0100 Subject: [PATCH 5/6] CCM-23784: Remove dry-run argument --- .../update-stale-pending-letters/src/index.ts | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/scripts/maintenance/update-stale-pending-letters/src/index.ts b/scripts/maintenance/update-stale-pending-letters/src/index.ts index 2aa76c76e..2fb867825 100644 --- a/scripts/maintenance/update-stale-pending-letters/src/index.ts +++ b/scripts/maintenance/update-stale-pending-letters/src/index.ts @@ -33,15 +33,7 @@ function outputFilePaths(timestamp: string) { }; } -function parseDryRunArg(): boolean { - const arg = process.argv.find((value) => value.startsWith("--dry-run=")); - if (!arg) { - return true; - } - return arg.split("=")[1] !== "false"; -} - -async function logTargetAccount(logger: Logger, dryRun: boolean) { +async function logTargetAccount(logger: Logger) { const stsClient = new STSClient({}); const identity = await stsClient.send(new GetCallerIdentityCommand({})); @@ -51,7 +43,6 @@ async function logTargetAccount(logger: Logger, dryRun: boolean) { arn: identity.Arn, region: await stsClient.config.region(), tableName: TABLE_NAME, - dryRun, }); // Give the operator a window to abort if the logged account/table is wrong @@ -62,9 +53,8 @@ async function logTargetAccount(logger: Logger, dryRun: boolean) { async function main() { const logger = pino(); - const dryRun = parseDryRunArg(); - await logTargetAccount(logger, dryRun); + await logTargetAccount(logger); const ddbClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(ddbClient); @@ -85,10 +75,6 @@ async function main() { let errorCount = 0; async function processLetter(letter: { id: string; supplierId: string }) { - if (dryRun) { - return; - } - try { await letterRepo.touchLetter(letter.supplierId, letter.id); updatedCount += 1; @@ -140,7 +126,7 @@ async function main() { await closeStream(failedIdsStream); logger.info({ - description: dryRun ? "DRY RUN complete" : "Run complete", + description: "Run complete", matchedCount, updatedCount, errorCount, From 98902c00e5f35724e3747a2c33d057c95d14d883 Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 18 Sep 2026 16:26:22 +0100 Subject: [PATCH 6/6] remove new record requirment from enqueue --- lambdas/update-letter-queue/src/update-letter-queue.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lambdas/update-letter-queue/src/update-letter-queue.ts b/lambdas/update-letter-queue/src/update-letter-queue.ts index cc91a0079..f55a408b4 100644 --- a/lambdas/update-letter-queue/src/update-letter-queue.ts +++ b/lambdas/update-letter-queue/src/update-letter-queue.ts @@ -33,7 +33,7 @@ export default function createHandler(deps: Deps): Handler { const ddbRecord = extractPayload(record, deps); try { - if (isNewPendingLetter(ddbRecord)) { + if (isPendingLetter(ddbRecord)) { const letter = extractNewOrUpdatedLetter(ddbRecord); const added = await addPendingLetterToQueue(letter, deps); updateDeltas(deltasBySupplierId, letter.supplierId, added); @@ -135,12 +135,11 @@ function recordProcessing( } } -function isNewPendingLetter(record: DynamoDBRecord): boolean { - const isInsert = record.eventName === "INSERT"; +function isPendingLetter(record: DynamoDBRecord): boolean { const newImage = record.dynamodb?.NewImage; const isPending = newImage?.status?.S === "PENDING"; - return isInsert && isPending; + return isPending; } function isNoLongerPending(record: DynamoDBRecord): boolean {