From d56c1aee5650ec4921925a16cbb1ea8416a0b2dc Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 13:06:16 +0100 Subject: [PATCH 1/8] script outline --- scripts/update-failed-letters.ts | 41 ++++++++++++ .../letter-status-fixer/package.json | 16 +++++ .../letter-status-fixer/src/cli/index.ts | 65 +++++++++++++++++++ .../infrastructure/letters-repo-factory.ts | 14 ++++ .../letter-status-fixer/tsconfig.json | 17 +++++ 5 files changed, 153 insertions(+) create mode 100644 scripts/update-failed-letters.ts create mode 100644 scripts/utilities/letter-status-fixer/package.json create mode 100644 scripts/utilities/letter-status-fixer/src/cli/index.ts create mode 100644 scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts create mode 100644 scripts/utilities/letter-status-fixer/tsconfig.json diff --git a/scripts/update-failed-letters.ts b/scripts/update-failed-letters.ts new file mode 100644 index 000000000..eae988363 --- /dev/null +++ b/scripts/update-failed-letters.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env tsx + +// Script: update-failed-letters.ts +// Usage: tsx scripts/update-failed-letters.ts +// Description: Looks up letters for a given supplierId and status, and updates letter statuses to FAILED where groupId is longer than 100 characters. + +import { LetterRepository } from "@internal/datastore/src/letter-repository"; +import { getDbContext } from "@internal/datastore/src/db-context"; +import pino from "pino"; + +const logger = pino({ level: "info" }); + +async function main() { + const [supplierId, status] = process.argv.slice(2); + if (!supplierId || !status) { + logger.error("Usage: tsx scripts/update-failed-letters.ts "); + process.exit(1); + } + + const db = await getDbContext(); + const letterRepo = new LetterRepository(db, logger); + + logger.info(`Looking up letters for supplierId=${supplierId}, status=${status}`); + const letters = await letterRepo.getLettersBySupplierAndStatus(supplierId, status); + + let updatedCount = 0; + for (const letter of letters) { + if (letter.groupId && letter.groupId.length > 100) { + logger.info(`Updating letter ${letter.id} (groupId length: ${letter.groupId.length}) to FAILED`); + await letterRepo.updateLetterStatus(letter.id, "FAILED"); + updatedCount++; + } + } + + logger.info(`Updated ${updatedCount} letters to FAILED.`); +} + +main().catch((err) => { + logger.error({ err }, "Script failed"); + process.exit(1); +}); diff --git a/scripts/utilities/letter-status-fixer/package.json b/scripts/utilities/letter-status-fixer/package.json new file mode 100644 index 000000000..829e6cab8 --- /dev/null +++ b/scripts/utilities/letter-status-fixer/package.json @@ -0,0 +1,16 @@ +{ + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.984.0", + "@aws-sdk/lib-dynamodb": "^3.1008.0", + "pino": "^10.3.0", + "yargs": "^17.7.2" + }, + "main": "src/cli/index.ts", + "name": "letter-status-fixer", + "private": true, + "scripts": { + "fix-status": "tsx src/cli/index.ts fix-status" + }, + "type": "module", + "version": "0.1.0" +} diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts new file mode 100644 index 000000000..7ea9896b3 --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -0,0 +1,65 @@ +import { hideBin } from "yargs/helpers"; +import yargs from "yargs"; +import { QueryCommand, QueryCommandOutput, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { createLetterDocClient } from "../infrastructure/letters-repo-factory"; + +async function updateFailedLetters(environment: string, supplierId: string, status: string) { + const { docClient, log, config } = createLetterDocClient(environment); + const compoundKey = `${supplierId}#${status}`; + + let lastKey: Record | undefined = undefined; + let updatedCount = 0; + + do { + const queryCmd = new QueryCommand({ + TableName: config.lettersTableName, + IndexName: config.supplierStatusIndex, + KeyConditionExpression: "supplierStatus = :ss", + ExpressionAttributeValues: { ":ss": compoundKey }, + ExclusiveStartKey: lastKey, + }); + const result = await docClient.send(queryCmd) as QueryCommandOutput; + for (const item of result.Items || []) { + if (item.groupId && item.groupId.length > 100) { + log.info(`Updating letter ${item.letterId} (groupId length: ${item.groupId.length}) to FAILED`); + const updateCmd = new UpdateCommand({ + TableName: config.lettersTableName, + Key: { letterId: item.letterId }, + UpdateExpression: "SET #status = :failed", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { ":failed": "FAILED" }, + }); + await docClient.send(updateCmd); + updatedCount++; + } + } + lastKey = result.LastEvaluatedKey; + } while (lastKey); + + log.info(`Updated ${updatedCount} letters to FAILED.`); +} + +async function main() { + await yargs(hideBin(process.argv)) + .command( + "fix-status", + "Update letters with long groupId to FAILED", + { + environment: { type: "string", demandOption: true }, + supplierId: { type: "string", demandOption: true }, + status: { type: "string", demandOption: true }, + }, + async (argv) => { + await updateFailedLetters(argv.environment, argv.supplierId, argv.status); + } + ) + .demandCommand(1) + .parse(); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts new file mode 100644 index 000000000..4c1be49d4 --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts @@ -0,0 +1,14 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import pino from "pino"; + +export function createLetterDocClient(environment: string) { + const ddbClient = new DynamoDBClient({}); + const docClient = DynamoDBDocumentClient.from(ddbClient); + const log = pino(); + const config = { + lettersTableName: process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`, + supplierStatusIndex: process.env.SUPPLIER_STATUS_INDEX || "supplierStatus", + }; + return { docClient, log, config }; +} diff --git a/scripts/utilities/letter-status-fixer/tsconfig.json b/scripts/utilities/letter-status-fixer/tsconfig.json new file mode 100644 index 000000000..8f611225e --- /dev/null +++ b/scripts/utilities/letter-status-fixer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "module": "ES2020", + "moduleResolution": "node", + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} From 03fd48ef35306dab10d7433bd9478b3d2308be99 Mon Sep 17 00:00:00 2001 From: Vlasis-Perdikidis Date: Fri, 22 May 2026 12:16:25 +0000 Subject: [PATCH 2/8] run npm install --- package-lock.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b997674e0..fc1f808e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,7 +116,7 @@ }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.18", + "version": "1.0.19", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", @@ -15472,6 +15472,10 @@ "node": ">=10" } }, + "node_modules/letter-status-fixer": { + "resolved": "scripts/utilities/letter-status-fixer", + "link": true + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -22379,6 +22383,15 @@ "version": "1.0.1", "license": "MIT" }, + "scripts/utilities/letter-status-fixer": { + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.984.0", + "@aws-sdk/lib-dynamodb": "^3.1008.0", + "pino": "^10.3.0", + "yargs": "^17.7.2" + } + }, "scripts/utilities/letter-test-data": { "name": "nhs-notify-supplier-api-letter-test-data-utility", "version": "0.0.1", From f0a4eebd5233eae66bffcf0e5caf4ac5c0611854 Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 13:44:38 +0100 Subject: [PATCH 3/8] remove attempt 1 --- scripts/update-failed-letters.ts | 41 -------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 scripts/update-failed-letters.ts diff --git a/scripts/update-failed-letters.ts b/scripts/update-failed-letters.ts deleted file mode 100644 index eae988363..000000000 --- a/scripts/update-failed-letters.ts +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env tsx - -// Script: update-failed-letters.ts -// Usage: tsx scripts/update-failed-letters.ts -// Description: Looks up letters for a given supplierId and status, and updates letter statuses to FAILED where groupId is longer than 100 characters. - -import { LetterRepository } from "@internal/datastore/src/letter-repository"; -import { getDbContext } from "@internal/datastore/src/db-context"; -import pino from "pino"; - -const logger = pino({ level: "info" }); - -async function main() { - const [supplierId, status] = process.argv.slice(2); - if (!supplierId || !status) { - logger.error("Usage: tsx scripts/update-failed-letters.ts "); - process.exit(1); - } - - const db = await getDbContext(); - const letterRepo = new LetterRepository(db, logger); - - logger.info(`Looking up letters for supplierId=${supplierId}, status=${status}`); - const letters = await letterRepo.getLettersBySupplierAndStatus(supplierId, status); - - let updatedCount = 0; - for (const letter of letters) { - if (letter.groupId && letter.groupId.length > 100) { - logger.info(`Updating letter ${letter.id} (groupId length: ${letter.groupId.length}) to FAILED`); - await letterRepo.updateLetterStatus(letter.id, "FAILED"); - updatedCount++; - } - } - - logger.info(`Updated ${updatedCount} letters to FAILED.`); -} - -main().catch((err) => { - logger.error({ err }, "Script failed"); - process.exit(1); -}); From 0e0a52fa92f183961b777bba8c4874e0efe7499e Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 14:13:06 +0100 Subject: [PATCH 4/8] script updates --- .../letter-status-fixer/src/cli/index.ts | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts index 7ea9896b3..43138d96e 100644 --- a/scripts/utilities/letter-status-fixer/src/cli/index.ts +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -1,27 +1,40 @@ -import { hideBin } from "yargs/helpers"; import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; import { QueryCommand, QueryCommandOutput, UpdateCommand } from "@aws-sdk/lib-dynamodb"; import { createLetterDocClient } from "../infrastructure/letters-repo-factory"; +import fs from "fs"; +import path from "path"; + +const commandOptions = { + environment: { type: "string" as const, demandOption: true }, + supplierId: { type: "string" as const, demandOption: true }, + status: { type: "string" as const, demandOption: true }, +}; async function updateFailedLetters(environment: string, supplierId: string, status: string) { const { docClient, log, config } = createLetterDocClient(environment); const compoundKey = `${supplierId}#${status}`; - let lastKey: Record | undefined = undefined; let updatedCount = 0; + let failedCount = 0; + const logFile = path.resolve(process.cwd(), `updated-letters-${Date.now()}.log`); + const failuresFile = path.resolve(process.cwd(), `failed-letters-${Date.now()}.log`); do { const queryCmd = new QueryCommand({ TableName: config.lettersTableName, IndexName: config.supplierStatusIndex, KeyConditionExpression: "supplierStatus = :ss", - ExpressionAttributeValues: { ":ss": compoundKey }, + FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", + ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, ExclusiveStartKey: lastKey, }); + const result = await docClient.send(queryCmd) as QueryCommandOutput; + for (const item of result.Items || []) { - if (item.groupId && item.groupId.length > 100) { - log.info(`Updating letter ${item.letterId} (groupId length: ${item.groupId.length}) to FAILED`); + try { + log.info(`Updating letter ${item.letterId} (groupId length: ${item.groupId?.length}) to FAILED`); const updateCmd = new UpdateCommand({ TableName: config.lettersTableName, Key: { letterId: item.letterId }, @@ -30,13 +43,19 @@ async function updateFailedLetters(environment: string, supplierId: string, stat ExpressionAttributeValues: { ":failed": "FAILED" }, }); await docClient.send(updateCmd); + fs.appendFileSync(logFile, `${item.letterId}\n`, "utf8"); updatedCount++; + } catch (err) { + log.error({ err }, `Failed to update letter ${item.letterId}`); + fs.appendFileSync(failuresFile, `${item.letterId}\n`, "utf8"); + failedCount++; } } lastKey = result.LastEvaluatedKey; } while (lastKey); - log.info(`Updated ${updatedCount} letters to FAILED.`); + log.info(`Updated ${updatedCount} letters to FAILED. IDs written to ${logFile}`); + log.info(`${failedCount} failed updates written to ${failuresFile}`); } async function main() { @@ -44,13 +63,12 @@ async function main() { .command( "fix-status", "Update letters with long groupId to FAILED", - { - environment: { type: "string", demandOption: true }, - supplierId: { type: "string", demandOption: true }, - status: { type: "string", demandOption: true }, - }, + commandOptions, async (argv) => { - await updateFailedLetters(argv.environment, argv.supplierId, argv.status); + const environment = argv.environment as string; + const supplierId = argv.supplierId as string; + const status = argv.status as string; + await updateFailedLetters(environment, supplierId, status); } ) .demandCommand(1) From d5f83b57ad016e9eed8eafc5896a87264f57d385 Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 14:35:19 +0100 Subject: [PATCH 5/8] lint, units, types --- .../letter-status-fixer/jest.config.ts | 62 +++++++++++++ .../letter-status-fixer/package.json | 6 +- .../src/__test__/letter-status-fixer.test.ts | 90 +++++++++++++++++++ .../letter-status-fixer/src/cli/index.ts | 47 ++++++---- .../infrastructure/letters-repo-factory.ts | 4 +- .../letter-status-fixer/tsconfig.json | 15 +--- 6 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 scripts/utilities/letter-status-fixer/jest.config.ts create mode 100644 scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts diff --git a/scripts/utilities/letter-status-fixer/jest.config.ts b/scripts/utilities/letter-status-fixer/jest.config.ts new file mode 100644 index 000000000..445325b8f --- /dev/null +++ b/scripts/utilities/letter-status-fixer/jest.config.ts @@ -0,0 +1,62 @@ +import type { Config } from "jest"; + +export const baseJestConfig: Config = { + preset: "ts-jest", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // The directory where Jest should output its coverage files + coverageDirectory: "./.reports/unit/coverage", + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + // Use this configuration option to add custom reporters to Jest + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + // The test environment that will be used for testing + testEnvironment: "jsdom", +}; + +const utilsJestConfig = { + ...baseJestConfig, + + testEnvironment: "node", + + coveragePathIgnorePatterns: [ + ...(baseJestConfig.coveragePathIgnorePatterns ?? []), + "cli/index.ts", + "helpers/s3_helpers.ts", + "letter-repo-factory.ts", + ], +}; + +export default utilsJestConfig; diff --git a/scripts/utilities/letter-status-fixer/package.json b/scripts/utilities/letter-status-fixer/package.json index 829e6cab8..d549304a3 100644 --- a/scripts/utilities/letter-status-fixer/package.json +++ b/scripts/utilities/letter-status-fixer/package.json @@ -9,7 +9,11 @@ "name": "letter-status-fixer", "private": true, "scripts": { - "fix-status": "tsx src/cli/index.ts fix-status" + "fix-status": "tsx src/cli/index.ts fix-status", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" }, "type": "module", "version": "0.1.0" diff --git a/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts b/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts new file mode 100644 index 000000000..ff8af6d2c --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts @@ -0,0 +1,90 @@ +import { QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import * as fs from "node:fs"; + +// Import after mocks +import { updateFailedLetters } from "../cli"; + +jest.mock("@aws-sdk/lib-dynamodb"); +jest.mock("fs"); + +const mockSend = jest.fn(); +const mockLog = { info: jest.fn(), error: jest.fn() }; + +jest.mock("../infrastructure/letters-repo-factory", () => ({ + createLetterDocClient: () => ({ + docClient: { send: mockSend }, + log: mockLog, + config: { + lettersTableName: "test-table", + supplierStatusIndex: "test-index", + }, + }), +})); + +describe("updateFailedLetters", () => { + const environment = "test-env"; + const supplierId = "SUP123"; + const status = "PENDING"; + // logFile and failuresFile are generated dynamically in the implementation, so we check for .log in assertions + + let fsMock: jest.Mocked; + beforeEach(() => { + jest.clearAllMocks(); + fsMock = fs as jest.Mocked; + fsMock.appendFileSync.mockClear(); + fsMock.writeFileSync.mockClear(); + }); + + it("updates all matching letters and logs IDs", async () => { + mockSend + .mockImplementationOnce(() => ({ + Items: [ + { letterId: "id1", groupId: "x".repeat(101) }, + { letterId: "id2", groupId: "y".repeat(102) }, + ], + LastEvaluatedKey: undefined, + })) + .mockImplementation(() => ({})); // For UpdateCommand + + await updateFailedLetters(environment, supplierId, status); + + expect(mockSend).toHaveBeenCalledWith(expect.any(QueryCommand)); + expect(mockSend).toHaveBeenCalledWith(expect.any(UpdateCommand)); + expect(fs.appendFileSync).toHaveBeenCalledWith( + expect.stringMatching(/updated-letters-\d+\.log/), + expect.stringContaining("id1"), + "utf8", + ); + expect(fs.appendFileSync).toHaveBeenCalledWith( + expect.stringMatching(/updated-letters-\d+\.log/), + expect.stringContaining("id2"), + "utf8", + ); + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringContaining("Updated 2 letters to FAILED"), + ); + }); + + it("logs failed updates to failures file", async () => { + mockSend + .mockImplementationOnce(() => ({ + Items: [{ letterId: "id3", groupId: "z".repeat(120) }], + LastEvaluatedKey: undefined, + })) + .mockImplementationOnce(() => { + throw new Error("fail"); + }); + + await updateFailedLetters(environment, supplierId, status); + + expect(fs.appendFileSync).toHaveBeenCalledWith( + expect.stringMatching(/failed-letters-\d+\.log/), + expect.stringContaining("id3"), + "utf8", + ); + expect(mockLog.error).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("Failed to update letter id3"), + ); + }); +}); diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts index 43138d96e..005f685d8 100644 --- a/scripts/utilities/letter-status-fixer/src/cli/index.ts +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -1,9 +1,9 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { QueryCommand, QueryCommandOutput, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import fs from "node:fs"; +import path from "node:path"; import { createLetterDocClient } from "../infrastructure/letters-repo-factory"; -import fs from "fs"; -import path from "path"; const commandOptions = { environment: { type: "string" as const, demandOption: true }, @@ -11,14 +11,25 @@ const commandOptions = { status: { type: "string" as const, demandOption: true }, }; -async function updateFailedLetters(environment: string, supplierId: string, status: string) { - const { docClient, log, config } = createLetterDocClient(environment); +/* eslint-disable import-x/prefer-default-export */ +export async function updateFailedLetters( + environment: string, + supplierId: string, + status: string, +) { + const { config, docClient, log } = createLetterDocClient(environment); const compoundKey = `${supplierId}#${status}`; - let lastKey: Record | undefined = undefined; + let lastKey: Record | undefined; let updatedCount = 0; let failedCount = 0; - const logFile = path.resolve(process.cwd(), `updated-letters-${Date.now()}.log`); - const failuresFile = path.resolve(process.cwd(), `failed-letters-${Date.now()}.log`); + const logFile = path.resolve( + process.cwd(), + `updated-letters-${Date.now()}.log`, + ); + const failuresFile = path.resolve( + process.cwd(), + `failed-letters-${Date.now()}.log`, + ); do { const queryCmd = new QueryCommand({ @@ -30,11 +41,13 @@ async function updateFailedLetters(environment: string, supplierId: string, stat ExclusiveStartKey: lastKey, }); - const result = await docClient.send(queryCmd) as QueryCommandOutput; + const result = await docClient.send(queryCmd); for (const item of result.Items || []) { try { - log.info(`Updating letter ${item.letterId} (groupId length: ${item.groupId?.length}) to FAILED`); + log.info( + `Updating letter ${item.letterId} (groupId length: ${item.groupId?.length}) to FAILED`, + ); const updateCmd = new UpdateCommand({ TableName: config.lettersTableName, Key: { letterId: item.letterId }, @@ -44,17 +57,19 @@ async function updateFailedLetters(environment: string, supplierId: string, stat }); await docClient.send(updateCmd); fs.appendFileSync(logFile, `${item.letterId}\n`, "utf8"); - updatedCount++; - } catch (err) { - log.error({ err }, `Failed to update letter ${item.letterId}`); + updatedCount += 1; + } catch (error) { + log.error({ err: error }, `Failed to update letter ${item.letterId}`); fs.appendFileSync(failuresFile, `${item.letterId}\n`, "utf8"); - failedCount++; + failedCount += 1; } } lastKey = result.LastEvaluatedKey; } while (lastKey); - log.info(`Updated ${updatedCount} letters to FAILED. IDs written to ${logFile}`); + log.info( + `Updated ${updatedCount} letters to FAILED. IDs written to ${logFile}`, + ); log.info(`${failedCount} failed updates written to ${failuresFile}`); } @@ -69,7 +84,7 @@ async function main() { const supplierId = argv.supplierId as string; const status = argv.status as string; await updateFailedLetters(environment, supplierId, status); - } + }, ) .demandCommand(1) .parse(); diff --git a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts index 4c1be49d4..374c624b5 100644 --- a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts +++ b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts @@ -2,12 +2,14 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import pino from "pino"; +/* eslint-disable import-x/prefer-default-export */ export function createLetterDocClient(environment: string) { const ddbClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(ddbClient); const log = pino(); const config = { - lettersTableName: process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`, + lettersTableName: + process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`, supplierStatusIndex: process.env.SUPPLIER_STATUS_INDEX || "supplierStatus", }; return { docClient, log, config }; diff --git a/scripts/utilities/letter-status-fixer/tsconfig.json b/scripts/utilities/letter-status-fixer/tsconfig.json index 8f611225e..730d18ddb 100644 --- a/scripts/utilities/letter-status-fixer/tsconfig.json +++ b/scripts/utilities/letter-status-fixer/tsconfig.json @@ -1,17 +1,8 @@ { - "compilerOptions": { - "esModuleInterop": true, - "module": "ES2020", - "moduleResolution": "node", - "outDir": "dist", - "resolveJsonModule": true, - "rootDir": "src", - "skipLibCheck": true, - "strict": true, - "target": "ES2022" - }, + "compilerOptions": {}, "extends": "../../../tsconfig.base.json", "include": [ - "src/**/*" + "src/**/*", + "jest.config.ts" ] } From 5ba1d6a4737e876fb50d41da3b7599d7a740ec80 Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 15:09:16 +0100 Subject: [PATCH 6/8] package updates --- package-lock.json | 6 +++--- .../utilities/letter-status-fixer/src/cli/index.ts | 11 +++++------ .../src/infrastructure/letters-repo-factory.ts | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc1f808e5..9fe8533d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15962,9 +15962,9 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts index 005f685d8..2b7fb2897 100644 --- a/scripts/utilities/letter-status-fixer/src/cli/index.ts +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -90,9 +90,8 @@ async function main() { .parse(); } -if (require.main === module) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts index 374c624b5..523e577ab 100644 --- a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts +++ b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts @@ -10,7 +10,7 @@ export function createLetterDocClient(environment: string) { const config = { lettersTableName: process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`, - supplierStatusIndex: process.env.SUPPLIER_STATUS_INDEX || "supplierStatus", + supplierStatusIndex: process.env.SUPPLIER_STATUS_INDEX || "supplierStatus-index", }; return { docClient, log, config }; } From ea347f89847044d801697550da33f3ee22f3fdc7 Mon Sep 17 00:00:00 2001 From: Mark Slowey Date: Fri, 22 May 2026 15:19:53 +0100 Subject: [PATCH 7/8] identify bad filter expression --- .../letter-status-fixer/src/cli/index.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts index 2b7fb2897..2bd73a03c 100644 --- a/scripts/utilities/letter-status-fixer/src/cli/index.ts +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -9,6 +9,7 @@ const commandOptions = { environment: { type: "string" as const, demandOption: true }, supplierId: { type: "string" as const, demandOption: true }, status: { type: "string" as const, demandOption: true }, + dryrun: { type: "boolean" as const, demandOption: false, default: true}, }; /* eslint-disable import-x/prefer-default-export */ @@ -16,19 +17,21 @@ export async function updateFailedLetters( environment: string, supplierId: string, status: string, + dryrun: boolean ) { const { config, docClient, log } = createLetterDocClient(environment); const compoundKey = `${supplierId}#${status}`; let lastKey: Record | undefined; let updatedCount = 0; let failedCount = 0; + const dryrunSuffix = dryrun ? "-dryrun" : ""; const logFile = path.resolve( process.cwd(), - `updated-letters-${Date.now()}.log`, + `updated-letters-${Date.now()}${dryrunSuffix}.log`, ); const failuresFile = path.resolve( process.cwd(), - `failed-letters-${Date.now()}.log`, + `failed-letters-${Date.now()}${dryrunSuffix}.log`, ); do { @@ -36,31 +39,42 @@ export async function updateFailedLetters( TableName: config.lettersTableName, IndexName: config.supplierStatusIndex, KeyConditionExpression: "supplierStatus = :ss", - FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", - ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, + // FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", + // ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, + ExpressionAttributeValues: { ":ss": compoundKey }, ExclusiveStartKey: lastKey, }); + log.info(queryCmd); + const result = await docClient.send(queryCmd); + // TODO: filter length here + + log.info( + `Found ${result.Items?.length || 0} letters with supplierId ${supplierId} and status ${status} that have groupId longer than 100 characters.`, + ); + for (const item of result.Items || []) { try { log.info( - `Updating letter ${item.letterId} (groupId length: ${item.groupId?.length}) to FAILED`, + `Updating letter ${item.id} (groupId length: ${item.groupId?.length}) to FAILED`, ); const updateCmd = new UpdateCommand({ TableName: config.lettersTableName, - Key: { letterId: item.letterId }, + Key: { letterId: item.id }, UpdateExpression: "SET #status = :failed", ExpressionAttributeNames: { "#status": "status" }, ExpressionAttributeValues: { ":failed": "FAILED" }, }); - await docClient.send(updateCmd); - fs.appendFileSync(logFile, `${item.letterId}\n`, "utf8"); + if(!dryrun) { + await docClient.send(updateCmd); + } + fs.appendFileSync(logFile, `${item.id}\n`, "utf8"); updatedCount += 1; } catch (error) { - log.error({ err: error }, `Failed to update letter ${item.letterId}`); - fs.appendFileSync(failuresFile, `${item.letterId}\n`, "utf8"); + log.error({ err: error }, `Failed to update letter ${item.id}`); + fs.appendFileSync(failuresFile, `${item.id}\n`, "utf8"); failedCount += 1; } } @@ -83,7 +97,8 @@ async function main() { const environment = argv.environment as string; const supplierId = argv.supplierId as string; const status = argv.status as string; - await updateFailedLetters(environment, supplierId, status); + const dryrun = argv.dryrun as boolean; + await updateFailedLetters(environment, supplierId, status, dryrun); }, ) .demandCommand(1) From 141df9179558e45438be3ff75b5bdde68b6835bb Mon Sep 17 00:00:00 2001 From: Vlasis-Perdikidis Date: Fri, 22 May 2026 15:02:25 +0000 Subject: [PATCH 8/8] fix unit tests and lint errors --- package-lock.json | 1 + .../letter-status-fixer/package.json | 1 + .../src/__test__/letter-status-fixer.test.ts | 11 ++++---- .../letter-status-fixer/src/cli/index.ts | 28 +++++++++---------- .../infrastructure/letters-repo-factory.ts | 3 +- 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9fe8533d2..dd253431d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22388,6 +22388,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", + "@jest/globals": "^30.2.0", "pino": "^10.3.0", "yargs": "^17.7.2" } diff --git a/scripts/utilities/letter-status-fixer/package.json b/scripts/utilities/letter-status-fixer/package.json index d549304a3..590556ebf 100644 --- a/scripts/utilities/letter-status-fixer/package.json +++ b/scripts/utilities/letter-status-fixer/package.json @@ -2,6 +2,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", + "@jest/globals": "^30.2.0", "pino": "^10.3.0", "yargs": "^17.7.2" }, diff --git a/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts b/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts index ff8af6d2c..1a25cb64f 100644 --- a/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts +++ b/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts @@ -2,6 +2,7 @@ import { QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; import * as fs from "node:fs"; // Import after mocks +import { expect, jest } from "@jest/globals"; import { updateFailedLetters } from "../cli"; jest.mock("@aws-sdk/lib-dynamodb"); @@ -39,14 +40,14 @@ describe("updateFailedLetters", () => { mockSend .mockImplementationOnce(() => ({ Items: [ - { letterId: "id1", groupId: "x".repeat(101) }, - { letterId: "id2", groupId: "y".repeat(102) }, + { id: "id1", groupId: "x".repeat(101) }, + { id: "id2", groupId: "y".repeat(102) }, ], LastEvaluatedKey: undefined, })) .mockImplementation(() => ({})); // For UpdateCommand - await updateFailedLetters(environment, supplierId, status); + await updateFailedLetters(environment, supplierId, status, false); expect(mockSend).toHaveBeenCalledWith(expect.any(QueryCommand)); expect(mockSend).toHaveBeenCalledWith(expect.any(UpdateCommand)); @@ -68,14 +69,14 @@ describe("updateFailedLetters", () => { it("logs failed updates to failures file", async () => { mockSend .mockImplementationOnce(() => ({ - Items: [{ letterId: "id3", groupId: "z".repeat(120) }], + Items: [{ id: "id3", groupId: "z".repeat(120) }], LastEvaluatedKey: undefined, })) .mockImplementationOnce(() => { throw new Error("fail"); }); - await updateFailedLetters(environment, supplierId, status); + await updateFailedLetters(environment, supplierId, status, false); expect(fs.appendFileSync).toHaveBeenCalledWith( expect.stringMatching(/failed-letters-\d+\.log/), diff --git a/scripts/utilities/letter-status-fixer/src/cli/index.ts b/scripts/utilities/letter-status-fixer/src/cli/index.ts index 2bd73a03c..7f2468862 100644 --- a/scripts/utilities/letter-status-fixer/src/cli/index.ts +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -9,7 +9,7 @@ const commandOptions = { environment: { type: "string" as const, demandOption: true }, supplierId: { type: "string" as const, demandOption: true }, status: { type: "string" as const, demandOption: true }, - dryrun: { type: "boolean" as const, demandOption: false, default: true}, + dryrun: { type: "boolean" as const, demandOption: false, default: true }, }; /* eslint-disable import-x/prefer-default-export */ @@ -17,7 +17,7 @@ export async function updateFailedLetters( environment: string, supplierId: string, status: string, - dryrun: boolean + dryrun: boolean, ) { const { config, docClient, log } = createLetterDocClient(environment); const compoundKey = `${supplierId}#${status}`; @@ -39,9 +39,8 @@ export async function updateFailedLetters( TableName: config.lettersTableName, IndexName: config.supplierStatusIndex, KeyConditionExpression: "supplierStatus = :ss", - // FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", - // ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, - ExpressionAttributeValues: { ":ss": compoundKey }, + FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", + ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, ExclusiveStartKey: lastKey, }); @@ -49,8 +48,6 @@ export async function updateFailedLetters( const result = await docClient.send(queryCmd); - // TODO: filter length here - log.info( `Found ${result.Items?.length || 0} letters with supplierId ${supplierId} and status ${status} that have groupId longer than 100 characters.`, ); @@ -67,8 +64,8 @@ export async function updateFailedLetters( ExpressionAttributeNames: { "#status": "status" }, ExpressionAttributeValues: { ":failed": "FAILED" }, }); - if(!dryrun) { - await docClient.send(updateCmd); + if (!dryrun) { + await docClient.send(updateCmd); } fs.appendFileSync(logFile, `${item.id}\n`, "utf8"); updatedCount += 1; @@ -97,7 +94,7 @@ async function main() { const environment = argv.environment as string; const supplierId = argv.supplierId as string; const status = argv.status as string; - const dryrun = argv.dryrun as boolean; + const { dryrun } = argv; await updateFailedLetters(environment, supplierId, status, dryrun); }, ) @@ -105,8 +102,9 @@ async function main() { .parse(); } - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts index 523e577ab..e4a50dec0 100644 --- a/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts +++ b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts @@ -10,7 +10,8 @@ export function createLetterDocClient(environment: string) { const config = { lettersTableName: process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`, - supplierStatusIndex: process.env.SUPPLIER_STATUS_INDEX || "supplierStatus-index", + supplierStatusIndex: + process.env.SUPPLIER_STATUS_INDEX || "supplierStatus-index", }; return { docClient, log, config }; }