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 {