diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 91e5c470..1dee209f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -83,7 +83,8 @@ }, "mounts": [ "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", - "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached" + "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached", + "source=${localEnv:HOME}/.npmrc,target=/home/vscode/.npmrc,type=bind,consistency=cached" ], "name": "Devcontainer", "postCreateCommand": "scripts/devcontainer/postcreatecommand.sh" diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 6063d464..43a26a83 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -72,7 +72,7 @@ jobs: needs: detect-terraform-changes if: needs.detect-terraform-changes.outputs.terraform_changed == 'true' permissions: - contents: write + contents: write steps: - name: "Checkout code" uses: actions/checkout@v5 @@ -164,8 +164,6 @@ jobs: registry-url: 'https://npm.pkg.github.com' - name: "Setup ASDF" uses: asdf-vm/actions/setup@v4 - - name: "Perform Setup" - uses: ./.github/actions/setup - name: "Trivy Scan" uses: ./.github/actions/trivy count-lines-of-code: @@ -288,7 +286,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.nodejs_version }} - registry-url: 'https://npm.pkg.github.com' + registry-url: "https://npm.pkg.github.com" - name: check if local version differs from latest published version id: check-version diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 3b03118a..82da554b 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -38,7 +38,7 @@ env: permissions: id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout + contents: read # This is required for actions/checkout packages: read # This is required for downloading from GitHub Package Registry jobs: @@ -49,6 +49,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -73,6 +78,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -142,6 +152,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: @@ -168,6 +183,11 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v5 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.nodejs_version }} + registry-url: "https://npm.pkg.github.com" - name: "Cache node_modules" uses: actions/cache@v4 with: diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 150af054..607e80e2 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -12,6 +12,8 @@ No requirements. | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | | [ca\_pem\_filename](#input\_ca\_pem\_filename) | Filename for the CA truststore file within the s3 bucket | `string` | `null` | no | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"supapi"` | no | +| [core\_account\_id](#input\_core\_account\_id) | AWS Account ID for Core | `string` | `"000000000000"` | no | +| [core\_environment](#input\_core\_environment) | Environment of Core | `string` | `"prod"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [enable\_backups](#input\_enable\_backups) | Enable backups | `bool` | `false` | no | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | @@ -22,6 +24,7 @@ No requirements. | [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | | [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | | [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no | +| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
}
| no | | [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | | [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no | diff --git a/infrastructure/terraform/components/api/ddb_table_letters.tf b/infrastructure/terraform/components/api/ddb_table_letters.tf index 6a3c3e48..44c882c4 100644 --- a/infrastructure/terraform/components/api/ddb_table_letters.tf +++ b/infrastructure/terraform/components/api/ddb_table_letters.tf @@ -2,8 +2,8 @@ resource "aws_dynamodb_table" "letters" { name = "${local.csi}-letters" billing_mode = "PAY_PER_REQUEST" - hash_key = "supplierId" - range_key = "id" + hash_key = "id" + range_key = "supplierId" ttl { attribute_name = "ttl" diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 65f5be98..2e1fb32a 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -1,7 +1,7 @@ module "upsert_letter" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip" - function_name = "upsert-letter" + function_name = "upsert_letter" description = "Update or Insert the letter data in the letters table" aws_account_id = var.aws_account_id @@ -22,7 +22,7 @@ module "upsert_letter" { function_code_base_path = local.aws_lambda_functions_dir_path function_code_dir = "upsert-letter/dist" function_include_common = true - handler_function_name = "handler" + handler_function_name = "upsertLetterHandler" runtime = "nodejs22.x" memory = 128 timeout = 29 @@ -35,7 +35,9 @@ module "upsert_letter" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_lambda_env_vars, {}) + lambda_env_vars = merge(local.common_lambda_env_vars, { + VARIANT_MAP = jsonencode(var.letter_variant_map) + }) } data "aws_iam_policy_document" "upsert_letter_lambda" { @@ -58,7 +60,10 @@ data "aws_iam_policy_document" "upsert_letter_lambda" { effect = "Allow" actions = [ - "dynamodb:PutItem" + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:UpdateItem" ] resources = [ diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index d3843a29..f1a8a080 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -135,6 +135,15 @@ variable "eventpub_control_plane_bus_arn" { default = "" } +variable "letter_variant_map" { + type = map(object({ supplierId = string, specId = string })) + default = { + "lv1" = { supplierId = "supplier1", specId = "spec1" }, + "lv2" = { supplierId = "supplier1", specId = "spec2" }, + "lv3" = { supplierId = "supplier2", specId = "spec3" } + } +} + variable "core_account_id" { type = string description = "AWS Account ID for Core" @@ -145,4 +154,5 @@ variable "core_environment" { type = string description = "Environment of Core" default = "prod" + } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index f3606b92..1d364b9f 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -51,8 +51,8 @@ const createLetterTableCommand = new CreateTableCommand({ TableName: "letters", BillingMode: "PAY_PER_REQUEST", KeySchema: [ - { AttributeName: "supplierId", KeyType: "HASH" }, // Partition key - { AttributeName: "id", KeyType: "RANGE" }, // Sort key + { AttributeName: "id", KeyType: "HASH" }, // Partition key (letter ID) + { AttributeName: "supplierId", KeyType: "RANGE" }, // Sort key ], GlobalSecondaryIndexes: [ { diff --git a/internal/datastore/src/__test__/heathcheck.test.ts b/internal/datastore/src/__test__/heathcheck.test.ts index 315144d7..40545364 100644 --- a/internal/datastore/src/__test__/heathcheck.test.ts +++ b/internal/datastore/src/__test__/heathcheck.test.ts @@ -24,6 +24,10 @@ describe("DBHealthcheck", () => { await deleteTables(db); }); + afterAll(async () => { + await db.container.stop(); + }); + it("passes when the database is available", async () => { const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); await expect(dbHealthCheck.check()).resolves.not.toThrow(); diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 6b0c5294..393c3810 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -7,15 +7,15 @@ import { setupDynamoDBContainer, } from "./db"; import { LetterRepository } from "../letter-repository"; -import { Letter } from "../types"; +import { InsertLetter, Letter, UpdateLetter } from "../types"; import { LogStream, createTestLogger } from "./logs"; -import { LetterDto } from "../../../../lambdas/api-handler/src/contracts/letters"; function createLetter( supplierId: string, letterId: string, status: Letter["status"] = "PENDING", -): Omit { +): InsertLetter { + const now = new Date().toISOString(); return { id: letterId, supplierId, @@ -23,11 +23,19 @@ function createLetter( groupId: "group1", url: `s3://bucket/${letterId}.pdf`, status, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, + source: "/data-plane/letter-rendering/pdf", + subject: `client/1/letter-request/${letterId}`, + billingRef: "specification1", }; } +function assertDateBetween(date: number, before: number, after: number) { + expect(date).toBeGreaterThanOrEqual(before); + expect(date).toBeLessThanOrEqual(after); +} + // Database tests can take longer, especially with setup and teardown jest.setTimeout(30_000); @@ -66,18 +74,47 @@ describe("LetterRepository", () => { expect(letter.status).toBe(status); } + function assertTtl(ttl: number, before: number, after: number) { + const expectedLower = Math.floor( + before / 1000 + 60 * 60 * db.config.lettersTtlHours, + ); + const expectedUpper = Math.floor( + after / 1000 + 60 * 60 * db.config.lettersTtlHours, + ); + expect(ttl).toBeGreaterThanOrEqual(expectedLower); + expect(ttl).toBeLessThanOrEqual(expectedUpper); + } + test("adds a letter to the database", async () => { const supplierId = "supplier1"; const letterId = "letter1"; + const before = Date.now(); + await letterRepository.putLetter(createLetter(supplierId, letterId)); + const after = Date.now(); + const letter = await letterRepository.getLetterById(supplierId, letterId); expect(letter).toBeDefined(); expect(letter.id).toBe(letterId); expect(letter.supplierId).toBe(supplierId); + assertDateBetween(new Date(letter.createdAt).valueOf(), before, after); + assertDateBetween(new Date(letter.updatedAt).valueOf(), before, after); + assertDateBetween( + new Date(letter.supplierStatusSk).valueOf(), + before, + after, + ); + expect(letter.supplierStatus).toBe("supplier1#PENDING"); + expect(letter.url).toBe("s3://bucket/letter1.pdf"); + expect(letter.specificationId).toBe("specification1"); + expect(letter.groupId).toBe("group1"); expect(letter.reasonCode).toBeUndefined(); expect(letter.reasonText).toBeUndefined(); + expect(letter.subject).toBe(`client/1/letter-request/${letterId}`); + expect(letter.billingRef).toBe("specification1"); + assertTtl(letter.ttl, before, after); }); test("fetches a letter by id", async () => { @@ -90,6 +127,9 @@ describe("LetterRepository", () => { specificationId: "specification1", groupId: "group1", status: "PENDING", + url: "s3://bucket/letter1.pdf", + source: "/data-plane/letter-rendering/pdf", + subject: "client/1/letter-request/letter1", }), ); }); @@ -122,18 +162,18 @@ describe("LetterRepository", () => { }); test("updates a letter's status in the database", async () => { - const letter = createLetter("supplier1", "letter1", "PENDING"); + const letter = createLetter("supplier1", "letter1"); await letterRepository.putLetter(letter); await checkLetterStatus("supplier1", "letter1", "PENDING"); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "REJECTED", reasonCode: "R01", reasonText: "Reason text", }; - await letterRepository.updateLetterStatus(letterDto); + await letterRepository.updateLetterStatus(updateLetter); const updatedLetter = await letterRepository.getLetterById( "supplier1", @@ -147,9 +187,7 @@ describe("LetterRepository", () => { test("updates a letter's updatedAt date", async () => { jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - await letterRepository.putLetter( - createLetter("supplier1", "letter1", "PENDING"), - ); + await letterRepository.putLetter(createLetter("supplier1", "letter1")); const originalLetter = await letterRepository.getLetterById( "supplier1", "letter1", @@ -159,7 +197,7 @@ describe("LetterRepository", () => { // Month is zero-indexed in JavaScript Date // Day is one-indexed jest.setSystemTime(new Date(2020, 1, 2)); - const letterDto: LetterDto = { + const letterDto: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", @@ -175,13 +213,13 @@ describe("LetterRepository", () => { }); test("can't update a letter that does not exist", async () => { - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; await expect( - letterRepository.updateLetterStatus(letterDto), + letterRepository.updateLetterStatus(updateLetter), ).rejects.toThrow( "Letter with id letter1 not found for supplier supplier1", ); @@ -193,13 +231,13 @@ describe("LetterRepository", () => { lettersTableName: "nonexistent-table", }); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; await expect( - misconfiguredRepository.updateLetterStatus(letterDto), + misconfiguredRepository.updateLetterStatus(updateLetter), ).rejects.toThrow("Cannot do operations on a non-existent table"); }); @@ -238,12 +276,12 @@ describe("LetterRepository", () => { ); expect(pendingLetters.letters).toHaveLength(2); - const letterDto: LetterDto = { + const updateLetter: UpdateLetter = { id: "letter1", supplierId: "supplier1", status: "DELIVERED", }; - await letterRepository.updateLetterStatus(letterDto); + await letterRepository.updateLetterStatus(updateLetter); const remainingLetters = await letterRepository.getLettersByStatus( "supplier1", "PENDING", @@ -313,7 +351,7 @@ describe("LetterRepository", () => { url: "s3://bucket/invalid-letter.pdf", status: "PENDING", supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, @@ -405,15 +443,36 @@ describe("LetterRepository", () => { }); test("should batch write letters to the database", async () => { - const letters = [ + const before = Date.now(); + + await letterRepository.unsafePutLetterBatch([ createLetter("supplier1", "letter1"), createLetter("supplier1", "letter2"), createLetter("supplier1", "letter3"), - ]; + ]); - await letterRepository.putLetterBatch(letters); + const after = Date.now(); + + const letter = await letterRepository.getLetterById("supplier1", "letter1"); + expect(letter).toBeDefined(); + expect(letter.id).toBe("letter1"); + expect(letter.supplierId).toBe("supplier1"); + assertDateBetween(new Date(letter.createdAt).valueOf(), before, after); + assertDateBetween(new Date(letter.updatedAt).valueOf(), before, after); + assertDateBetween( + new Date(letter.supplierStatusSk).valueOf(), + before, + after, + ); + expect(letter.supplierStatus).toBe("supplier1#PENDING"); + expect(letter.url).toBe("s3://bucket/letter1.pdf"); + expect(letter.specificationId).toBe("specification1"); + expect(letter.groupId).toBe("group1"); + expect(letter.reasonCode).toBeUndefined(); + expect(letter.reasonText).toBeUndefined(); + expect(letter.subject).toBe("client/1/letter-request/letter1"); + assertTtl(letter.ttl, before, after); - await checkLetterStatus("supplier1", "letter1", "PENDING"); await checkLetterStatus("supplier1", "letter2", "PENDING"); await checkLetterStatus("supplier1", "letter3", "PENDING"); }); @@ -426,7 +485,7 @@ describe("LetterRepository", () => { const sendSpy = jest.spyOn(db.docClient, "send"); - await letterRepository.putLetterBatch(letters); + await letterRepository.unsafePutLetterBatch(letters); expect(sendSpy).toHaveBeenCalledTimes(3); @@ -440,7 +499,7 @@ describe("LetterRepository", () => { letters[0] = createLetter("supplier1", "letter1"); letters[2] = createLetter("supplier1", "letter3"); - await letterRepository.putLetterBatch(letters); + await letterRepository.unsafePutLetterBatch(letters); await checkLetterStatus("supplier1", "letter1", "PENDING"); await checkLetterStatus("supplier1", "letter3", "PENDING"); @@ -452,7 +511,7 @@ describe("LetterRepository", () => { lettersTableName: "nonexistent-table", }); await expect( - misconfiguredRepository.putLetterBatch([ + misconfiguredRepository.unsafePutLetterBatch([ createLetter("supplier1", "letter1"), ]), ).rejects.toThrow("Cannot do operations on a non-existent table"); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index e9fd85d0..d3af885e 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -9,8 +9,14 @@ import { } from "@aws-sdk/lib-dynamodb"; import { Logger } from "pino"; import { z } from "zod"; -import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from "./types"; -import { LetterDto } from "../../../lambdas/api-handler/src/contracts/letters"; +import { + InsertLetter, + Letter, + LetterBase, + LetterSchema, + LetterSchemaBase, + UpdateLetter, +} from "./types"; export type PagingOptions = Partial<{ exclusiveStartKey: Record; @@ -33,13 +39,11 @@ export class LetterRepository { readonly config: LetterRepositoryConfig, ) {} - async putLetter( - letter: Omit, - ): Promise { + async putLetter(letter: InsertLetter): Promise { const letterDb: Letter = { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: new Date().toISOString(), + supplierStatusSk: new Date().toISOString(), // needs to be an ISO timestamp as Db sorts alphabetically ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), @@ -66,9 +70,7 @@ export class LetterRepository { return LetterSchema.parse(letterDb); } - async putLetterBatch( - letters: Omit[], - ): Promise { + async unsafePutLetterBatch(letters: InsertLetter[]): Promise { let lettersDb: Letter[] = []; for (let i = 0; i < letters.length; i++) { const letter = letters[i]; @@ -77,7 +79,7 @@ export class LetterRepository { lettersDb.push({ ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: Date.now().toString(), + supplierStatusSk: new Date().toISOString(), // needs to be an ISO timestamp as Db sorts alphabetically ttl: Math.floor( Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, ), @@ -107,8 +109,8 @@ export class LetterRepository { new GetCommand({ TableName: this.config.lettersTableName, Key: { - supplierId, id: letterId, + supplierId, }, }), ); @@ -161,7 +163,7 @@ export class LetterRepository { }; } - async updateLetterStatus(letterToUpdate: LetterDto): Promise { + async updateLetterStatus(letterToUpdate: UpdateLetter): Promise { this.log.debug( `Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`, ); @@ -192,8 +194,8 @@ export class LetterRepository { new UpdateCommand({ TableName: this.config.lettersTableName, Key: { - supplierId: letterToUpdate.supplierId, id: letterToUpdate.id, + supplierId: letterToUpdate.supplierId, }, UpdateExpression: updateExpression, ConditionExpression: "attribute_exists(id)", // Ensure letter exists diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 65b6df88..a0b9f719 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -48,6 +48,9 @@ export const LetterSchema = LetterSchemaBase.extend({ supplierStatus: z.string().describe("Secondary index PK"), supplierStatusSk: z.string().describe("Secondary index SK"), ttl: z.int(), + source: z.string(), + subject: z.string(), + billingRef: z.string(), }).describe("Letter"); /** @@ -58,6 +61,18 @@ export const LetterSchema = LetterSchemaBase.extend({ export type Letter = z.infer; export type LetterBase = z.infer; +export type InsertLetter = Omit< + Letter, + "ttl" | "supplierStatus" | "supplierStatusSk" +>; +export type UpdateLetter = { + id: string; + supplierId: string; + status: Letter["status"]; + reasonCode?: string; + reasonText?: string; +}; + export const MISchemaBase = z.object({ id: z.string(), lineItem: z.string(), diff --git a/internal/events/package.json b/internal/events/package.json index 2d799646..8005d13e 100644 --- a/internal/events/package.json +++ b/internal/events/package.json @@ -50,5 +50,5 @@ "typecheck": "tsc --noEmit" }, "types": "dist/index.d.ts", - "version": "1.0.5" + "version": "1.0.6" } diff --git a/internal/events/schemas/examples/letter.ACCEPTED.json b/internal/events/schemas/examples/letter.ACCEPTED.json index f9a1177c..c6533b93 100644 --- a/internal/events/schemas/examples/letter.ACCEPTED.json +++ b/internal/events/schemas/examples/letter.ACCEPTED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/schemas/examples/letter.FORWARDED.json b/internal/events/schemas/examples/letter.FORWARDED.json index bf12ed69..6661fe6c 100644 --- a/internal/events/schemas/examples/letter.FORWARDED.json +++ b/internal/events/schemas/examples/letter.FORWARDED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/schemas/examples/letter.RETURNED.json b/internal/events/schemas/examples/letter.RETURNED.json index e273029d..f0cfa376 100644 --- a/internal/events/schemas/examples/letter.RETURNED.json +++ b/internal/events/schemas/examples/letter.RETURNED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/domain/letter.ts b/internal/events/src/domain/letter.ts index 7e854d41..67ed8bbe 100644 --- a/internal/events/src/domain/letter.ts +++ b/internal/events/src/domain/letter.ts @@ -76,6 +76,13 @@ The identifier will be included as the origin domain in the subject of any corre examples: ["1y3q9v1zzzz"], }), + billingRef: z.string().meta({ + title: "Billing Reference", + description: + "A billing reference determined for this letter based on its specification", + examples: ["1y3q9v1zzzz"], + }), + supplierId: z.string().meta({ title: "Supplier ID", description: "Supplier ID allocated to the letter during creation.", diff --git a/internal/events/src/events/__tests__/letter-status-change-events.test.ts b/internal/events/src/events/__tests__/letter-status-change-events.test.ts index 25541fc5..48215545 100644 --- a/internal/events/src/events/__tests__/letter-status-change-events.test.ts +++ b/internal/events/src/events/__tests__/letter-status-change-events.test.ts @@ -37,6 +37,7 @@ describe("LetterStatus event validations", () => { }), domainId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", specificationId: "1y3q9v1zzzz", + billingRef: "1y3q9v1zzzz", groupId: "client_template", status, }), diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json index 5458449d..192ea5e2 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json index b7a1358b..54000422 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json index e39b8366..7ffac10f 100644 --- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json +++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json index 6b7b4c45..28c6111f 100644 --- a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json +++ b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/internal/events/src/events/__tests__/testData/letter.RETURNED.json b/internal/events/src/events/__tests__/testData/letter.RETURNED.json index 8a4a9e44..07b28154 100644 --- a/internal/events/src/events/__tests__/testData/letter.RETURNED.json +++ b/internal/events/src/events/__tests__/testData/letter.RETURNED.json @@ -1,5 +1,6 @@ { "data": { + "billingRef": "1y3q9v1zzzz", "domainId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "groupId": "client_template", "origin": { diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts index 5f671701..0c3d1418 100644 --- a/lambdas/api-handler/src/contracts/letters.ts +++ b/lambdas/api-handler/src/contracts/letters.ts @@ -15,19 +15,17 @@ export const LetterStatusSchema = z.enum([ "DELIVERED", ]); -export const LetterDtoSchema = z +export const UpdateLetterCommandSchema = z .object({ id: z.string(), - status: LetterStatusSchema, supplierId: z.string(), - specificationId: z.string().optional(), - groupId: z.string().optional(), + status: LetterStatusSchema, reasonCode: z.string().optional(), reasonText: z.string().optional(), }) .strict(); -export type LetterDto = z.infer; +export type UpdateLetterCommand = z.infer; export const PatchLetterRequestResourceSchema = z .object({ diff --git a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts index 4f249f95..908ba1a3 100644 --- a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts @@ -3,13 +3,13 @@ import { mockDeep } from "jest-mock-extended"; import { S3Client } from "@aws-sdk/client-s3"; import pino from "pino"; import { LetterRepository } from "@internal/datastore/src"; -import { LetterDto } from "../../contracts/letters"; +import { UpdateLetterCommand } from "../../contracts/letters"; import { EnvVars } from "../../config/env"; import { Deps } from "../../config/deps"; import createLetterStatusUpdateHandler from "../letter-status-update"; -const buildEvent = (lettersToUpdate: LetterDto[]): SQSEvent => { - const records: Partial[] = lettersToUpdate.map((letter) => { +const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => { + const records: Partial[] = updateLetterCommand.map((letter) => { return { messageId: `mid-${letter.id}`, body: JSON.stringify(letter), @@ -35,13 +35,11 @@ describe("createLetterStatusUpdateHandler", () => { }); it("processes letters successfully", async () => { - const lettersToUpdate: LetterDto[] = [ + const updateLetterCommands: UpdateLetterCommand[] = [ { id: "id1", status: "REJECTED", supplierId: "s1", - specificationId: "spec1", - groupId: "g1", reasonCode: "123", reasonText: "Reason text", }, @@ -62,9 +60,9 @@ describe("createLetterStatusUpdateHandler", () => { letterRepo: { updateLetterStatus: jest .fn() - .mockResolvedValueOnce(lettersToUpdate[0]) - .mockResolvedValueOnce(lettersToUpdate[1]) - .mockResolvedValueOnce(lettersToUpdate[2]), + .mockResolvedValueOnce(updateLetterCommands[0]) + .mockResolvedValueOnce(updateLetterCommands[1]) + .mockResolvedValueOnce(updateLetterCommands[2]), } as unknown as LetterRepository, logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, env: { @@ -84,22 +82,22 @@ describe("createLetterStatusUpdateHandler", () => { const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); await letterStatusUpdateHandler( - buildEvent(lettersToUpdate), + buildEvent(updateLetterCommands), context, callback, ); expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith( 1, - lettersToUpdate[0], + updateLetterCommands[0], ); expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith( 2, - lettersToUpdate[1], + updateLetterCommands[1], ); expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith( 3, - lettersToUpdate[2], + updateLetterCommands[2], ); }); @@ -126,7 +124,7 @@ describe("createLetterStatusUpdateHandler", () => { const context = mockDeep(); const callback = jest.fn(); - const letterToUpdate: LetterDto[] = [ + const updateLetterCommands: UpdateLetterCommand[] = [ { id: "id1", status: "ACCEPTED", @@ -137,13 +135,13 @@ describe("createLetterStatusUpdateHandler", () => { const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); await letterStatusUpdateHandler( - buildEvent(letterToUpdate), + buildEvent(updateLetterCommands), context, callback, ); expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledWith( - letterToUpdate[0], + updateLetterCommands[0], ); expect(mockedDeps.logger.error).toHaveBeenCalledWith( { diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index 6930db48..fa14e672 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -1,6 +1,10 @@ import { SQSEvent, SQSHandler } from "aws-lambda"; -import { LetterDto, LetterDtoSchema } from "../contracts/letters"; +import { + UpdateLetterCommand, + UpdateLetterCommandSchema, +} from "../contracts/letters"; import { Deps } from "../config/deps"; +import { mapToUpdateLetter } from "../mappers/letter-mapper"; export default function createLetterStatusUpdateHandler( deps: Deps, @@ -8,10 +12,11 @@ export default function createLetterStatusUpdateHandler( return async (event: SQSEvent) => { const tasks = event.Records.map(async (message) => { try { - const letterToUpdate: LetterDto = LetterDtoSchema.parse( - JSON.parse(message.body), + const letterToUpdate: UpdateLetterCommand = + UpdateLetterCommandSchema.parse(JSON.parse(message.body)); + await deps.letterRepo.updateLetterStatus( + mapToUpdateLetter(letterToUpdate), ); - await deps.letterRepo.updateLetterStatus(letterToUpdate); } catch (error) { deps.logger.error( { diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index 1e854970..6d0be3c2 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,16 +1,16 @@ import { APIGatewayProxyHandler } from "aws-lambda"; import { enqueueLetterUpdateRequests } from "../services/letter-operations"; import { - LetterDto, PatchLetterRequest, PatchLetterRequestSchema, + UpdateLetterCommand, } from "../contracts/letters"; import { ApiErrorDetail } from "../contracts/errors"; import ValidationError from "../errors/validation-error"; import { processError } from "../mappers/error-mapper"; import { assertNotEmpty } from "../utils/validation"; import { extractCommonIds } from "../utils/common-ids"; -import { mapPatchLetterToDto } from "../mappers/letter-mapper"; +import { mapToUpdateCommand } from "../mappers/letter-mapper"; import type { Deps } from "../config/deps"; export default function createPatchLetterHandler( @@ -57,19 +57,19 @@ export default function createPatchLetterHandler( throw typedError; } - const letterToUpdate: LetterDto = mapPatchLetterToDto( + const updateLetterCommand: UpdateLetterCommand = mapToUpdateCommand( patchLetterRequest, commonIds.value.supplierId, ); - if (letterToUpdate.id !== letterId) { + if (updateLetterCommand.id !== letterId) { throw new ValidationError( ApiErrorDetail.InvalidRequestLetterIdsMismatch, ); } await enqueueLetterUpdateRequests( - [letterToUpdate], + [updateLetterCommand], commonIds.value.correlationId, deps, ); diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index f9d73acd..d8f14787 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -7,7 +7,7 @@ import { } from "../contracts/letters"; import ValidationError from "../errors/validation-error"; import { processError } from "../mappers/error-mapper"; -import { mapPostLettersToDtoArray } from "../mappers/letter-mapper"; +import { mapToUpdateCommands } from "../mappers/letter-mapper"; import { enqueueLetterUpdateRequests } from "../services/letter-operations"; import { extractCommonIds } from "../utils/common-ids"; import { assertNotEmpty, requireEnvVar } from "../utils/validation"; @@ -72,10 +72,7 @@ export default function createPostLettersHandler( } await enqueueLetterUpdateRequests( - mapPostLettersToDtoArray( - postLettersRequest, - commonIds.value.supplierId, - ), + mapToUpdateCommands(postLettersRequest, commonIds.value.supplierId), commonIds.value.correlationId, deps, ); diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index c19eb972..fa7f9f81 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -12,18 +12,22 @@ import { describe("letter-mapper", () => { it("maps an internal Letter to a PatchLetterResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -42,20 +46,24 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a PatchLetterResponse with reasonCode and reasonText when present", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: PatchLetterResponse = mapToPatchLetterResponse(letter); @@ -76,18 +84,22 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a GetLetterResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -106,20 +118,24 @@ describe("letter-mapper", () => { }); it("maps an internal Letter to a GetLetterResponse with reasonCode and reasonText when present", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLetterResponse = mapToGetLetterResponse(letter); @@ -140,20 +156,24 @@ describe("letter-mapper", () => { }); it("maps an internal Letter collection to a GetLettersResponse", () => { + const date = new Date().toISOString(); const letter: Letter = { id: "abc123", status: "PENDING", supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: "https://example.com/letter/abc123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: date, + updatedAt: date, supplierStatus: "supplier1#PENDING", - supplierStatusSk: Date.now().toString(), + supplierStatusSk: date, ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; const result: GetLettersResponse = mapToGetLettersResponse([ diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index 3b13f905..3259d79b 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,15 +1,15 @@ -import { LetterBase, LetterStatus } from "@internal/datastore"; +import { LetterBase, LetterStatus, UpdateLetter } from "@internal/datastore"; import { GetLetterResponse, GetLetterResponseSchema, GetLettersResponse, GetLettersResponseSchema, - LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema, PostLettersRequest, PostLettersRequestResource, + UpdateLetterCommand, } from "../contracts/letters"; function letterToResourceResponse(letter: LetterBase) { @@ -38,10 +38,14 @@ function letterToGetLettersResourceResponse(letter: LetterBase) { }; } -export function mapPatchLetterToDto( +// -------------------------- +// Map request to command +// -------------------------- + +export function mapToUpdateCommand( request: PatchLetterRequest, supplierId: string, -): LetterDto { +): UpdateLetterCommand { return { id: request.data.id, supplierId, @@ -51,11 +55,11 @@ export function mapPatchLetterToDto( }; } -export function mapPostLettersToDtoArray( +export function mapToUpdateCommands( request: PostLettersRequest, supplierId: string, -): LetterDto[] { - return request.data.map((letterToUpdate: PostLettersRequestResource) => ({ +): UpdateLetterCommand[] { + return request.data.map( (letterToUpdate: PostLettersRequestResource) => ({ id: letterToUpdate.id, supplierId, status: LetterStatus.parse(letterToUpdate.attributes.status), @@ -64,6 +68,26 @@ export function mapPostLettersToDtoArray( })); } +// --------------------------------------------- +// Map letter command to repository type +// --------------------------------------------- + +export function mapToUpdateLetter( + updateLetter: UpdateLetterCommand, +): UpdateLetter { + return { + id: updateLetter.id, + supplierId: updateLetter.supplierId, + status: updateLetter.status, + reasonCode: updateLetter.reasonCode, + reasonText: updateLetter.reasonText, + }; +} + +// --------------------------------------------- +// Map internal datastore letter to response +// --------------------------------------------- + export function mapToPatchLetterResponse( letter: LetterBase, ): PatchLetterResponse { diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 550fbeca..c7cfd592 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -9,7 +9,7 @@ import { getLetterDataUrl, getLettersForSupplier, } from "../letter-operations"; -import { LetterDto } from "../../contracts/letters"; +import { UpdateLetterCommand } from "../../contracts/letters"; import { Deps } from "../../config/deps"; jest.mock("@aws-sdk/s3-request-presigner", () => ({ @@ -29,6 +29,7 @@ function makeLetter(id: string, status: Letter["status"]): Letter { status, supplierId: "supplier1", specificationId: "spec123", + billingRef: "spec123", groupId: "group123", url: `s3://letterDataBucket/${id}.pdf`, createdAt: new Date().toISOString(), @@ -38,6 +39,8 @@ function makeLetter(id: string, status: Letter["status"]): Letter { ttl: 123, reasonCode: "R01", reasonText: "Reason text", + source: "/data-plane/letter-rendering/pdf", + subject: "letter-rendering/source/letter/letter-id", }; } @@ -194,13 +197,11 @@ describe("enqueueLetterUpdateRequests function", () => { jest.clearAllMocks(); }); - const lettersToUpdate: LetterDto[] = [ + const updateLetterCommands: UpdateLetterCommand[] = [ { id: "id1", status: "REJECTED", supplierId: "s1", - specificationId: "spec1", - groupId: "g1", reasonCode: "123", reasonText: "Reason text", }, @@ -220,7 +221,7 @@ describe("enqueueLetterUpdateRequests function", () => { const deps: Deps = { sqsClient, logger, env } as Deps; const result = await enqueueLetterUpdateRequests( - lettersToUpdate, + updateLetterCommands, "correlationId1", deps, ); @@ -239,13 +240,11 @@ describe("enqueueLetterUpdateRequests function", () => { }, }, MessageBody: JSON.stringify({ - id: lettersToUpdate[0].id, - status: lettersToUpdate[0].status, - supplierId: lettersToUpdate[0].supplierId, - specificationId: lettersToUpdate[0].specificationId, - groupId: lettersToUpdate[0].groupId, - reasonCode: lettersToUpdate[0].reasonCode, - reasonText: lettersToUpdate[0].reasonText, + id: updateLetterCommands[0].id, + status: updateLetterCommands[0].status, + supplierId: updateLetterCommands[0].supplierId, + reasonCode: updateLetterCommands[0].reasonCode, + reasonText: updateLetterCommands[0].reasonText, }), }, }), @@ -263,9 +262,9 @@ describe("enqueueLetterUpdateRequests function", () => { }, }, MessageBody: JSON.stringify({ - id: lettersToUpdate[1].id, - status: lettersToUpdate[1].status, - supplierId: lettersToUpdate[1].supplierId, + id: updateLetterCommands[1].id, + status: updateLetterCommands[1].status, + supplierId: updateLetterCommands[1].supplierId, }), }, }), @@ -287,7 +286,7 @@ describe("enqueueLetterUpdateRequests function", () => { const deps: Deps = { sqsClient, logger, env } as Deps; const result = await enqueueLetterUpdateRequests( - lettersToUpdate, + updateLetterCommands, "correlationId1", deps, ); @@ -306,9 +305,9 @@ describe("enqueueLetterUpdateRequests function", () => { }, }, MessageBody: JSON.stringify({ - id: lettersToUpdate[1].id, - status: lettersToUpdate[1].status, - supplierId: lettersToUpdate[1].supplierId, + id: updateLetterCommands[1].id, + status: updateLetterCommands[1].status, + supplierId: updateLetterCommands[1].supplierId, }), }, }), @@ -319,9 +318,9 @@ describe("enqueueLetterUpdateRequests function", () => { { err: mockError, correlationId: "correlationId1", - letterId: lettersToUpdate[0].id, - letterStatus: lettersToUpdate[0].status, - supplierId: lettersToUpdate[0].supplierId, + letterId: updateLetterCommands[0].id, + letterStatus: updateLetterCommands[0].status, + supplierId: updateLetterCommands[0].supplierId, }, "Error enqueuing letter status update", ); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index c94e76b5..384d694a 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -3,7 +3,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { SendMessageCommand } from "@aws-sdk/client-sqs"; import NotFoundError from "../errors/not-found-error"; -import { LetterDto } from "../contracts/letters"; +import { UpdateLetterCommand } from "../contracts/letters"; import { ApiErrorDetail } from "../contracts/errors"; import { Deps } from "../config/deps"; @@ -82,33 +82,35 @@ export const getLetterDataUrl = async ( }; export async function enqueueLetterUpdateRequests( - updateRequests: LetterDto[], + updateLetterCommands: UpdateLetterCommand[], correlationId: string, deps: Deps, ) { - const tasks = updateRequests.map(async (request: LetterDto) => { - try { - const command = new SendMessageCommand({ - QueueUrl: deps.env.QUEUE_URL, - MessageAttributes: { - CorrelationId: { DataType: "String", StringValue: correlationId }, - }, - MessageBody: JSON.stringify(request), - }); - await deps.sqsClient.send(command); - } catch (error) { - deps.logger.error( - { - err: error, - correlationId, - letterId: request.id, - letterStatus: request.status, - supplierId: request.supplierId, - }, - "Error enqueuing letter status update", - ); - } - }); + const tasks = updateLetterCommands.map( + async (request: UpdateLetterCommand) => { + try { + const command = new SendMessageCommand({ + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { DataType: "String", StringValue: correlationId }, + }, + MessageBody: JSON.stringify(request), + }); + await deps.sqsClient.send(command); + } catch (error) { + deps.logger.error( + { + err: error, + correlationId, + letterId: request.id, + letterStatus: request.status, + supplierId: request.supplierId, + }, + "Error enqueuing letter status update", + ); + } + }, + ); await Promise.all(tasks); } diff --git a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts index 17c271a0..67211462 100644 --- a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts +++ b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts @@ -257,9 +257,14 @@ function generateLetter(status: LetterStatus, id?: string): LetterForEventPub { id: id || "1", status, specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", + createdAt: "2025-12-10T11:12:54Z", updatedAt: "2025-12-10T11:13:54Z", + url: "https://example.com/letter.pdf", + source: "test-source", + subject: "test-source/subject-id", }; } diff --git a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts index fe5b5a79..7139165d 100644 --- a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts @@ -7,12 +7,15 @@ describe("letter-mapper", () => { const letter = { id: "id1", specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", status: "PRINTED", reasonCode: "R02", reasonText: "Reason text", updatedAt: "2025-11-24T15:55:18.000Z", + source: "letter-rendering/source/test", + subject: "letter-rendering/source/letter/letter-id", } as Letter; const event = mapLetterToCloudEvent(letter); @@ -22,7 +25,7 @@ describe("letter-mapper", () => { expect(event.dataschema).toBe( `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.PRINTED.${event.dataschemaversion}.schema.json`, ); - expect(event.dataschemaversion).toBe("1.0.5"); + expect(event.dataschemaversion).toBe("1.0.6"); expect(event.subject).toBe("letter-origin/supplier-api/letter/id1"); expect(event.time).toBe("2025-11-24T15:55:18.000Z"); expect(event.recordedtime).toBe("2025-11-24T15:55:18.000Z"); @@ -30,14 +33,15 @@ describe("letter-mapper", () => { domainId: "id1", status: "PRINTED", specificationId: "spec1", + billingRef: "spec1", supplierId: "supplier1", groupId: "group1", reasonCode: "R02", reasonText: "Reason text", origin: { domain: "supplier-api", - source: "/data-plane/supplier-api/letters", - subject: "letter-origin/supplier-api/letter/id1", + source: "letter-rendering/source/test", + subject: "letter-rendering/source/letter/letter-id", event: event.id, }, }); diff --git a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts b/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts index 759f5f0f..34cd23c5 100644 --- a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts +++ b/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts @@ -22,14 +22,15 @@ export default function mapLetterToCloudEvent( domainId: letter.id as LetterEvent["data"]["domainId"], status: letter.status, specificationId: letter.specificationId, + billingRef: letter.billingRef, supplierId: letter.supplierId, groupId: letter.groupId, reasonCode: letter.reasonCode, reasonText: letter.reasonText, origin: { domain: "supplier-api", - source: "/data-plane/supplier-api/letters", - subject: `letter-origin/supplier-api/letter/${letter.id}`, + source: letter.source, + subject: letter.subject, event: eventId, }, }, diff --git a/lambdas/letter-updates-transformer/src/types.ts b/lambdas/letter-updates-transformer/src/types.ts index b1b7f4c7..34920991 100644 --- a/lambdas/letter-updates-transformer/src/types.ts +++ b/lambdas/letter-updates-transformer/src/types.ts @@ -1,10 +1,10 @@ -import { LetterSchemaBase, SupplierSchema } from "@internal/datastore"; -import { idRef } from "@internal/helpers"; +import { LetterSchema } from "@internal/datastore"; import { z } from "zod"; -export const LetterSchemaForEventPub = LetterSchemaBase.extend({ - supplierId: idRef(SupplierSchema, "id"), - updatedAt: z.string(), +export const LetterSchemaForEventPub = LetterSchema.omit({ + supplierStatus: true, + supplierStatusSk: true, + ttl: true, }); export type LetterForEventPub = z.infer; diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index af6f91bf..4ccd4d05 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -1,7 +1,16 @@ { "dependencies": { + "@aws-sdk/client-dynamodb": "^3.858.0", + "@aws-sdk/lib-dynamodb": "^3.858.0", + "@internal/datastore": "*", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.5", "@types/aws-lambda": "^8.10.148", - "esbuild": "^0.24.0" + "aws-lambda": "^1.0.7", + "esbuild": "^0.24.0", + "pino": "^9.7.0", + "zod": "^4.1.11" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/lambdas/upsert-letter/src/__tests__/index.test.ts b/lambdas/upsert-letter/src/__tests__/index.test.ts deleted file mode 100644 index d29b864b..00000000 --- a/lambdas/upsert-letter/src/__tests__/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Context } from "aws-lambda"; -import { mockDeep } from "jest-mock-extended"; -import handler from ".."; - -describe("event-logging Lambda", () => { - it("logs the input event and returns 200", async () => { - const event = { foo: "bar" }; - const context = mockDeep(); - const callback = jest.fn(); - const result = await handler(event, context, callback); - - expect(result).toEqual({ - statusCode: 200, - body: "Event logged", - }); - }); -}); diff --git a/lambdas/upsert-letter/src/config/__tests__/deps.test.ts b/lambdas/upsert-letter/src/config/__tests__/deps.test.ts new file mode 100644 index 00000000..a532da6f --- /dev/null +++ b/lambdas/upsert-letter/src/config/__tests__/deps.test.ts @@ -0,0 +1,53 @@ +import type { Deps } from "lambdas/upsert-letter/src/config/deps"; + +describe("createDependenciesContainer", () => { + const env = { + LETTERS_TABLE_NAME: "LettersTable", + LETTER_TTL_HOURS: 12_960, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + // pino + jest.mock("pino", () => ({ + __esModule: true, + default: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + })), + })); + + // Repo client + jest.mock("@internal/datastore", () => ({ + LetterRepository: jest.fn(), + })); + + // Env + jest.mock("../env", () => ({ envVars: env })); + }); + + test("constructs deps and wires repository config correctly", async () => { + // get current mock instances + const pinoMock = jest.requireMock("pino"); + const { LetterRepository } = jest.requireMock("@internal/datastore"); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createDependenciesContainer } = require("../deps"); + const deps: Deps = createDependenciesContainer(); + + expect(pinoMock.default).toHaveBeenCalledTimes(1); + + expect(LetterRepository).toHaveBeenCalledTimes(1); + const letterRepoCtorArgs = LetterRepository.mock.calls[0]; + expect(letterRepoCtorArgs[2]).toEqual({ + lettersTableName: "LettersTable", + lettersTtlHours: 12_960, + }); + + expect(deps.env).toEqual(env); + }); +}); diff --git a/lambdas/upsert-letter/src/config/__tests__/env.test.ts b/lambdas/upsert-letter/src/config/__tests__/env.test.ts new file mode 100644 index 00000000..3c3de230 --- /dev/null +++ b/lambdas/upsert-letter/src/config/__tests__/env.test.ts @@ -0,0 +1,48 @@ +import { ZodError } from "zod"; +/* eslint-disable @typescript-eslint/no-require-imports */ +/* Allow require imports to enable re-import of modules */ + +describe("lambdaEnv", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); // Clears cached modules + process.env = { ...OLD_ENV }; // Clone original env + }); + + afterAll(() => { + process.env = OLD_ENV; // Restore + }); + + it("should load all environment variables successfully", () => { + process.env.LETTERS_TABLE_NAME = "letters-table"; + process.env.LETTER_TTL_HOURS = "12960"; + process.env.VARIANT_MAP = `{ + "lv1": { + "supplierId": "supplier1", + "specId": "spec1" + } + }`; + + const { envVars } = require("../env"); + + expect(envVars).toEqual({ + LETTERS_TABLE_NAME: "letters-table", + LETTER_TTL_HOURS: 12_960, + VARIANT_MAP: { + lv1: { + supplierId: "supplier1", + specId: "spec1", + }, + }, + }); + }); + + it("should throw if a required env var is missing", () => { + process.env.LETTERS_TABLE_NAME = "table"; + process.env.LETTER_TTL_HOURS = "12960"; + process.env.VARIANT_MAP = undefined; + + expect(() => require("../env")).toThrow(ZodError); + }); +}); diff --git a/lambdas/upsert-letter/src/config/deps.ts b/lambdas/upsert-letter/src/config/deps.ts new file mode 100644 index 00000000..7320af6f --- /dev/null +++ b/lambdas/upsert-letter/src/config/deps.ts @@ -0,0 +1,39 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import pino from "pino"; +import { LetterRepository } from "@internal/datastore"; +import { EnvVars, envVars } from "./env"; + +export type Deps = { + letterRepo: LetterRepository; + logger: pino.Logger; + env: EnvVars; +}; + +function createDocumentClient(): DynamoDBDocumentClient { + const ddbClient = new DynamoDBClient({}); + return DynamoDBDocumentClient.from(ddbClient); +} + +function createLetterRepository( + log: pino.Logger, + // eslint-disable-next-line @typescript-eslint/no-shadow + envVars: EnvVars, +): LetterRepository { + const config = { + lettersTableName: envVars.LETTERS_TABLE_NAME, + lettersTtlHours: envVars.LETTER_TTL_HOURS, + }; + + return new LetterRepository(createDocumentClient(), log, config); +} + +export function createDependenciesContainer(): Deps { + const log = pino(); + + return { + letterRepo: createLetterRepository(log, envVars), + logger: log, + env: envVars, + }; +} diff --git a/lambdas/upsert-letter/src/config/env.ts b/lambdas/upsert-letter/src/config/env.ts new file mode 100644 index 00000000..ef527258 --- /dev/null +++ b/lambdas/upsert-letter/src/config/env.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +const LetterVariantSchema = z.record( + z.string(), + z.object({ + supplierId: z.string(), + specId: z.string(), + }), +); +export type LetterVariant = z.infer; + +const EnvVarsSchema = z.object({ + LETTERS_TABLE_NAME: z.string(), + LETTER_TTL_HOURS: z.coerce.number().int(), + VARIANT_MAP: z.string().transform((str, _) => { + const parsed = JSON.parse(str); + return LetterVariantSchema.parse(parsed); + }), +}); + +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); diff --git a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts new file mode 100644 index 00000000..a185e058 --- /dev/null +++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts @@ -0,0 +1,463 @@ +import { SNSMessage, SQSEvent } from "aws-lambda"; +import pino from "pino"; +import { LetterRepository } from "internal/datastore/src"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; +import createUpsertLetterHandler from "../upsert-handler"; +import { Deps } from "../../config/deps"; +import { EnvVars } from "../../config/env"; + +function createSqsRecord(msgId: string, body: string) { + return { + messageId: msgId, + receiptHandle: "", + body, + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + }; +} + +function createNotification( + event: + | LetterRequestPreparedEventV2 + | LetterRequestPreparedEvent + | LetterEvent, +): Partial { + return { + SignatureVersion: "", + Timestamp: "", + Signature: "", + SigningCertUrl: "", + MessageId: "", + Message: JSON.stringify(event), + MessageAttributes: {}, + Type: "Notification", + UnsubscribeUrl: "", + TopicArn: "", + Subject: "", + Token: "", + }; +} + +function createPreparedV1Event( + overrides: Partial = {}, +): LetterRequestPreparedEvent { + const now = new Date().toISOString(); + + return { + specversion: "1.0", + id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", + source: "/data-plane/letter-rendering/test", + subject: "client/client1/letter-request/letterRequest1", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", + time: now, + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", + dataschemaversion: "1.0.0", + data: { + domainId: overrides.domainId ?? "letter1", + letterVariantId: "lv1", + requestId: "request1", + requestItemId: "requestItem1", + requestItemPlanId: "requestItemPlan1", + clientId: "client1", + campaignId: "campaign1", + templateId: "template1", + url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", + sha256Hash: + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", + createdAt: now, + pageCount: 1, + status: "PREPARED", + }, + traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", + recordedtime: now, + severitynumber: 2, + severitytext: "INFO", + plane: "data", + }; +} + +function createPreparedV2Event( + overrides: Partial = {}, +): LetterRequestPreparedEventV2 { + return { + ...createPreparedV1Event(overrides), + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2", + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.0.schema.json", + dataschemaversion: "2.0.0", + }; +} + +function createSupplierStatusChangeEvent( + overrides: Partial = {}, +): LetterEvent { + const now = new Date().toISOString(); + + return $LetterEvent.parse({ + data: { + domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479", + groupId: "client_template", + origin: { + domain: "letter-rendering", + event: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + source: "/data-plane/letter-rendering/prod/render-pdf", + subject: + "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5", + }, + reasonCode: "R07", + reasonText: "No such address", + specificationId: "1y3q9v1zzzz", + billingRef: "1y3q9v1zzzz", + status: "RETURNED", + supplierId: "supplier1", + }, + datacontenttype: "application/json", + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.RETURNED.1.0.0.schema.json", + dataschemaversion: "1.0.0", + id: overrides.id ?? "23f1f09c-a555-4d9b-8405-0b33490bc920", + plane: "data", + recordedtime: now, + severitynumber: 2, + severitytext: "INFO", + source: "/data-plane/supplier-api/prod/update-status", + specversion: "1.0", + subject: + "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479", + time: now, + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + type: "uk.nhs.notify.supplier-api.letter.RETURNED.v1", + }); +} + +describe("createUpsertLetterHandler", () => { + const mockedDeps: jest.Mocked = { + letterRepo: { + putLetter: jest.fn(), + updateLetterStatus: jest.fn(), + } as unknown as LetterRepository, + logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, + env: { + LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME", + LETTER_TTL_HOURS: 12_960, + VARIANT_MAP: { + lv1: { + supplierId: "supplier1", + specId: "spec1", + }, + }, + } as EnvVars, + } as Deps; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("processes all records successfully and returns no batch failures", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "msg1", + JSON.stringify(createNotification(createPreparedV2Event())), + ), + createSqsRecord( + "msg2", + JSON.stringify(createNotification(createSupplierStatusChangeEvent())), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(0); + + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(1); + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledTimes(1); + + const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock + .calls[0][0]; + expect(firstArg.id).toBe("letter1"); + expect(firstArg.supplierId).toBe("supplier1"); + expect(firstArg.specificationId).toBe("spec1"); + expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf"); + expect(firstArg.status).toBe("PENDING"); + expect(firstArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + + const secondArg = (mockedDeps.letterRepo.updateLetterStatus as jest.Mock) + .mock.calls[0][0]; + expect(secondArg.id).toBe("f47ac10b-58cc-4372-a567-0e02b2c3d479"); + expect(secondArg.supplierId).toBe("supplier1"); + expect(secondArg.status).toBe("RETURNED"); + expect(secondArg.reasonCode).toBe("R07"); + expect(secondArg.reasonText).toBe("No such address"); + }); + + test("processes all v1 records successfully and returns no batch failures", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "msg1", + JSON.stringify(createNotification(createPreparedV1Event())), + ), + createSqsRecord( + "msg2", + JSON.stringify( + createNotification( + createPreparedV1Event({ + id: "7b9a03ca-342a-4150-b56b-989109c45614", + domainId: "letter2", + url: "s3://letterDataBucket/letter2.pdf", + }), + ), + ), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(0); + + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(2); + + const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock + .calls[0][0]; + expect(firstArg.id).toBe("letter1"); + expect(firstArg.supplierId).toBe("supplier1"); + expect(firstArg.specificationId).toBe("spec1"); + expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf"); + expect(firstArg.status).toBe("PENDING"); + expect(firstArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + + const secondArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock + .calls[1][0]; + expect(secondArg.id).toBe("letter2"); + expect(secondArg.supplierId).toBe("supplier1"); + expect(secondArg.specificationId).toBe("spec1"); + expect(secondArg.url).toBe("s3://letterDataBucket/letter2.pdf"); + expect(secondArg.status).toBe("PENDING"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(secondArg.groupId).toBe("client1campaign1template1"); + expect(firstArg.source).toBe("/data-plane/letter-rendering/test"); + }); + + test("invalid JSON body produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [createSqsRecord("bad-json", "this-is-not-json")], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json"); + + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-json", + ); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + }); + + test("invalid notification schema produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-notification-schema", + JSON.stringify({ not: "unexpected notification shape" }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe( + "bad-notification-schema", + ); + + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-notification-schema", + ); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + }); + + test("no event type produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-event-type", + JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ no: "type" }), + }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-type"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-event-type", + ); + }); + + test("invalid event type produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-event-type", + JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ type: "unexpected type" }), + }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-type"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-event-type", + ); + }); + + test("valid event type and invalid schema produces batch failure and logs error", async () => { + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "bad-event-schema", + JSON.stringify({ + Type: "Notification", + Message: JSON.stringify({ + type: "uk.nhs.notify.letter-rendering.letter-request.prepared", + some: "unexpected shape", + }), + }), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(result).toBeDefined(); + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-event-schema"); + expect(mockedDeps.letterRepo.putLetter).not.toHaveBeenCalled(); + expect(mockedDeps.letterRepo.updateLetterStatus).not.toHaveBeenCalled(); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][1]).toBe( + "Error processing upsert of record bad-event-schema", + ); + }); + + test("repository throwing for one record causes that message to be returned in batch failures while others succeed", async () => { + (mockedDeps.letterRepo.putLetter as jest.Mock) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("ddb error")); + + const evt: SQSEvent = { + Records: [ + createSqsRecord( + "ok-msg", + JSON.stringify( + createNotification( + createPreparedV2Event({ + id: "7b9a03ca-342a-4150-b56b-989109c45615", + domainId: "ok", + }), + ), + ), + ), + createSqsRecord( + "fail-msg", + JSON.stringify( + createNotification( + createPreparedV2Event({ + id: "7b9a03ca-342a-4150-b56b-989109c45616", + domainId: "fail", + }), + ), + ), + ), + ], + }; + + const result = await createUpsertLetterHandler(mockedDeps)( + evt, + {} as any, + {} as any, + ); + + expect(mockedDeps.letterRepo.putLetter).toHaveBeenCalledTimes(2); + + if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("fail-msg"); + + expect(mockedDeps.logger.error).toHaveBeenCalled(); + }); +}); diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts new file mode 100644 index 00000000..141e37ce --- /dev/null +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -0,0 +1,175 @@ +import { + SNSMessage, + SQSBatchItemFailure, + SQSEvent, + SQSHandler, + SQSRecord, +} from "aws-lambda"; +import { InsertLetter, UpdateLetter } from "@internal/datastore"; +import { + $LetterRequestPreparedEvent, + LetterRequestPreparedEvent, +} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; +import { + $LetterRequestPreparedEventV2, + LetterRequestPreparedEventV2, +} from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import z from "zod"; +import { Deps } from "../config/deps"; + +type SupplierSpec = { supplierId: string; specId: string }; +type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +type UpsertOperation = { + name: "Insert" | "Update"; + schemas: z.ZodSchema[]; + handler: (request: unknown, deps: Deps) => Promise; +}; + +// small envelope that must exist in all inputs +const TypeEnvelope = z.object({ type: z.string().min(1) }); + +function getOperationFromType(type: string): UpsertOperation { + if (type.startsWith("uk.nhs.notify.letter-rendering.letter-request.prepared")) + return { + name: "Insert", + schemas: [$LetterRequestPreparedEventV2, $LetterRequestPreparedEvent], + handler: async (request, deps) => { + const preparedRequest = request as PreparedEvents; + const supplierSpec: SupplierSpec = resolveSupplierForVariant( + preparedRequest.data.letterVariantId, + deps, + ); + const letterToInsert: InsertLetter = mapToInsertLetter( + preparedRequest, + supplierSpec.supplierId, + supplierSpec.specId, + supplierSpec.specId, //use specId for now + ); + await deps.letterRepo.putLetter(letterToInsert); + }, + }; + if (type.startsWith("uk.nhs.notify.supplier-api.letter")) + return { + name: "Update", + schemas: [$LetterEvent], + handler: async (request, deps) => { + const supplierEvent = request as LetterEvent; + const letterToUpdate: UpdateLetter = mapToUpdateLetter(supplierEvent); + await deps.letterRepo.updateLetterStatus(letterToUpdate); + }, + }; + throw new Error(`Unknown operation from type=${type}`); +} + +function mapToInsertLetter( + upsertRequest: PreparedEvents, + supplier: string, + spec: string, + billingRef: string, +): InsertLetter { + const now = new Date().toISOString(); + return { + id: upsertRequest.data.domainId, + supplierId: supplier, + status: "PENDING", + specificationId: spec, + groupId: + upsertRequest.data.clientId + + upsertRequest.data.campaignId + + upsertRequest.data.templateId, + url: upsertRequest.data.url, + source: upsertRequest.source, + subject: upsertRequest.subject, + createdAt: now, + updatedAt: now, + billingRef: billingRef, + }; +} + +function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter { + return { + id: upsertRequest.data.domainId, + supplierId: upsertRequest.data.supplierId, + status: upsertRequest.data.status, + reasonCode: upsertRequest.data.reasonCode, + reasonText: upsertRequest.data.reasonText, + }; +} + +function resolveSupplierForVariant( + variantId: string, + deps: Deps, +): SupplierSpec { + return deps.env.VARIANT_MAP[variantId]; +} + +function parseSNSNotification(record: SQSRecord) { + const notification = JSON.parse(record.body) as Partial; + if ( + notification.Type !== "Notification" || + typeof notification.Message !== "string" + ) { + throw new Error( + "SQS record does not contain SNS Notification with string Message", + ); + } + return notification.Message; +} + +function getType(event: unknown) { + const env = TypeEnvelope.safeParse(event); + if (!env.success) { + throw new Error("Missing or invalid envelope.type field"); + } + return env.data.type; +} + +async function runUpsert( + operation: UpsertOperation, + letterEvent: unknown, + deps: Deps, +) { + for (const schema of operation.schemas) { + const r = schema.safeParse(letterEvent); + if (r.success) { + await operation.handler(r.data, deps); + return; + } + } + // none matched + throw new Error("No matching schema for received message"); +} + +export default function createUpsertLetterHandler(deps: Deps): SQSHandler { + return async (event: SQSEvent) => { + const batchItemFailures: SQSBatchItemFailure[] = []; + + const tasks = event.Records.map(async (record) => { + try { + const message: string = parseSNSNotification(record); + + const letterEvent: unknown = JSON.parse(message); + + const type = getType(letterEvent); + + const operation = getOperationFromType(type); + + await runUpsert(operation, letterEvent, deps); + } catch (error) { + deps.logger.error( + { err: error, message: record.body }, + `Error processing upsert of record ${record.messageId}`, + ); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + }); + + await Promise.all(tasks); + + return { batchItemFailures }; + }; +} diff --git a/lambdas/upsert-letter/src/index.ts b/lambdas/upsert-letter/src/index.ts index a165560c..0c2fc9e2 100644 --- a/lambdas/upsert-letter/src/index.ts +++ b/lambdas/upsert-letter/src/index.ts @@ -1,12 +1,7 @@ -// Replace me with the actual code for your Lambda function -import { Handler } from "aws-lambda"; +import { createDependenciesContainer } from "./config/deps"; +import createUpsertLetterHandler from "./handler/upsert-handler"; -const handler: Handler = async (event) => { - console.log("Received event:", event); - return { - statusCode: 200, - body: "Event logged", - }; -}; +const container = createDependenciesContainer(); -export default handler; +// eslint-disable-next-line import-x/prefer-default-export +export const upsertLetterHandler = createUpsertLetterHandler(container); diff --git a/package-lock.json b/package-lock.json index f8f11e82..06d503e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2500,8 +2500,17 @@ "name": "nhs-notify-supplier-api-upsert-letter", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-dynamodb": "^3.858.0", + "@aws-sdk/lib-dynamodb": "^3.858.0", + "@internal/datastore": "*", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.0", + "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.5", "@types/aws-lambda": "^8.10.148", - "esbuild": "^0.24.0" + "aws-lambda": "^1.0.7", + "esbuild": "^0.24.0", + "pino": "^9.7.0", + "zod": "^4.1.11" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", @@ -2909,6 +2918,15 @@ "node": ">=18" } }, + "lambdas/upsert-letter/node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering": { + "version": "2.0.0", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/2.0.0/329230fced77de6141adb0fb991023d7f8ae10b7", + "integrity": "sha512-rTkq199jGm8+fMoB/3uvYOOiVCgYn9vN4LBBdCVB7cTnskMkI+5NJEjpxkvO/jqUr8a0JkZY8cRTN8xZ1nNaKQ==", + "license": "MIT", + "dependencies": { + "zod": "^4.0.17" + } + }, "lambdas/upsert-letter/node_modules/esbuild": { "version": "0.24.2", "hasInstallScript": true, @@ -2947,6 +2965,28 @@ "@esbuild/win32-x64": "0.24.2" } }, + "lambdas/upsert-letter/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", @@ -7525,6 +7565,16 @@ "zod": "^4.0.17" } }, + "node_modules/@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": { + "name": "@nhsdigital/nhs-notify-event-schemas-letter-rendering", + "version": "1.1.5", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-letter-rendering/1.1.5/7949805ee5a21ff934f798e73822774c64366677", + "integrity": "sha512-9Rx3dkYdVqwBbeE3YpJkdiu54fHyHFfeLFZGNRUuxX/dsi0obnJDxdPfBk07aK9jopDZmuVvKQS1O6Sigl+SuQ==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.11" + } + }, "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { "resolved": "internal/events", "link": true diff --git a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts index a8b2a0db..ad34b021 100644 --- a/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts +++ b/scripts/utilities/letter-test-data/src/__test__/helpers/create_letter_helpers.test.ts @@ -54,6 +54,9 @@ describe("Create letter helpers", () => { supplierId: "supplierId", updatedAt: "2020-02-01T00:00:00.000Z", url: "s3://bucketName/supplierId/targetFilename", + source: "/data-plane/letter-rendering/letter-test-data", + subject: "supplier-api/letter-test-data/letterId", + billingRef: "specificationId" }); }); @@ -81,6 +84,9 @@ describe("Create letter helpers", () => { status: "PENDING", createdAt: "2020-02-01T00:00:00.000Z", updatedAt: "2020-02-01T00:00:00.000Z", + source: "/data-plane/letter-rendering/letter-test-data", + subject: "supplier-api/letter-test-data/testLetterId", + billingRef: "testSpecId" }); }); }); diff --git a/scripts/utilities/letter-test-data/src/cli/index.ts b/scripts/utilities/letter-test-data/src/cli/index.ts index 915c392b..a3026200 100644 --- a/scripts/utilities/letter-test-data/src/cli/index.ts +++ b/scripts/utilities/letter-test-data/src/cli/index.ts @@ -174,7 +174,7 @@ async function main() { }; // Upload Letters - await letterRepository.putLetterBatch(letterDtos); + await letterRepository.unsafePutLetterBatch(letterDtos); console.log(`Created batch ${batchId} of ${letterDtos.length}`); }, diff --git a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts index 062154ec..e471bdb8 100644 --- a/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts +++ b/scripts/utilities/letter-test-data/src/helpers/create_letter_helpers.ts @@ -42,6 +42,9 @@ export async function createLetter(params: { status: status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + source: "/data-plane/letter-rendering/letter-test-data", + subject: `supplier-api/letter-test-data/${letterId}`, + billingRef: specificationId }; const letterRecord = await letterRepository.putLetter(letter); @@ -74,6 +77,9 @@ export function createLetterDto(params: { status: status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + source: "/data-plane/letter-rendering/letter-test-data", + subject: `supplier-api/letter-test-data/${letterId}`, + billingRef: specificationId }; return letter; diff --git a/tests/helpers/generate_fetch_testData.ts b/tests/helpers/generate_fetch_testData.ts index 2fd63aa6..4182ebe1 100644 --- a/tests/helpers/generate_fetch_testData.ts +++ b/tests/helpers/generate_fetch_testData.ts @@ -19,7 +19,8 @@ export interface SupplierApiLetters { url: string, ttl: string, reasonText: string, - status: string + status: string, + source: string }; export async function createTestData(supplierId: string): Promise {