diff --git a/package-lock.json b/package-lock.json index b997674e0..dd253431d 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", @@ -15958,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": { @@ -22379,6 +22383,16 @@ "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", + "@jest/globals": "^30.2.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", 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 new file mode 100644 index 000000000..590556ebf --- /dev/null +++ b/scripts/utilities/letter-status-fixer/package.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "main": "src/cli/index.ts", + "name": "letter-status-fixer", + "private": true, + "scripts": { + "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..1a25cb64f --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/__test__/letter-status-fixer.test.ts @@ -0,0 +1,91 @@ +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"); +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: [ + { id: "id1", groupId: "x".repeat(101) }, + { id: "id2", groupId: "y".repeat(102) }, + ], + LastEvaluatedKey: undefined, + })) + .mockImplementation(() => ({})); // For UpdateCommand + + await updateFailedLetters(environment, supplierId, status, false); + + 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: [{ id: "id3", groupId: "z".repeat(120) }], + LastEvaluatedKey: undefined, + })) + .mockImplementationOnce(() => { + throw new Error("fail"); + }); + + await updateFailedLetters(environment, supplierId, status, false); + + 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 new file mode 100644 index 000000000..7f2468862 --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/cli/index.ts @@ -0,0 +1,110 @@ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +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"; + +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 */ +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()}${dryrunSuffix}.log`, + ); + const failuresFile = path.resolve( + process.cwd(), + `failed-letters-${Date.now()}${dryrunSuffix}.log`, + ); + + do { + const queryCmd = new QueryCommand({ + TableName: config.lettersTableName, + IndexName: config.supplierStatusIndex, + KeyConditionExpression: "supplierStatus = :ss", + FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len", + ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 }, + ExclusiveStartKey: lastKey, + }); + + log.info(queryCmd); + + const result = await docClient.send(queryCmd); + + 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.id} (groupId length: ${item.groupId?.length}) to FAILED`, + ); + const updateCmd = new UpdateCommand({ + TableName: config.lettersTableName, + Key: { letterId: item.id }, + UpdateExpression: "SET #status = :failed", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { ":failed": "FAILED" }, + }); + 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.id}`); + fs.appendFileSync(failuresFile, `${item.id}\n`, "utf8"); + failedCount += 1; + } + } + lastKey = result.LastEvaluatedKey; + } while (lastKey); + + log.info( + `Updated ${updatedCount} letters to FAILED. IDs written to ${logFile}`, + ); + log.info(`${failedCount} failed updates written to ${failuresFile}`); +} + +async function main() { + await yargs(hideBin(process.argv)) + .command( + "fix-status", + "Update letters with long groupId to FAILED", + commandOptions, + async (argv) => { + const environment = argv.environment as string; + const supplierId = argv.supplierId as string; + const status = argv.status as string; + const { dryrun } = argv; + await updateFailedLetters(environment, supplierId, status, dryrun); + }, + ) + .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..e4a50dec0 --- /dev/null +++ b/scripts/utilities/letter-status-fixer/src/infrastructure/letters-repo-factory.ts @@ -0,0 +1,17 @@ +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`, + supplierStatusIndex: + process.env.SUPPLIER_STATUS_INDEX || "supplierStatus-index", + }; + 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..730d18ddb --- /dev/null +++ b/scripts/utilities/letter-status-fixer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": {}, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +}