diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index 01fe4c4be..29f18ba61 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -18,7 +18,7 @@ No requirements.
| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
| [disable\_gateway\_execute\_endpoint](#input\_disable\_gateway\_execute\_endpoint) | Disable the execution endpoint for the API Gateway | `bool` | `true` | no |
| [enable\_api\_data\_trace](#input\_enable\_api\_data\_trace) | Enable API Gateway data trace logging | `bool` | `false` | no |
-| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `false` | no |
+| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `true` | no |
| [enable\_sns\_delivery\_logging](#input\_enable\_sns\_delivery\_logging) | Enable SNS Delivery Failure Notifications | `bool` | `false` | no |
| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes |
| [eventpub\_control\_plane\_bus\_arn](#input\_eventpub\_control\_plane\_bus\_arn) | ARN of the EventBridge control plane bus for eventpub | `string` | `""` | no |
@@ -42,6 +42,8 @@ No requirements.
| Name | Source | Version |
|------|--------|---------|
+| [amendment\_event\_transformer](#module\_amendment\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
+| [amendments\_queue](#module\_amendments\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
| [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-eventpub.zip | n/a |
@@ -51,8 +53,6 @@ No requirements.
| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a |
-| [letter\_status\_update](#module\_letter\_status\_update) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
-| [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
| [mi\_updates\_transformer](#module\_mi\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a |
diff --git a/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
index ab3634c43..71ecd8085 100644
--- a/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
+++ b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf
@@ -1,12 +1,12 @@
resource "aws_lambda_event_source_mapping" "status_updates_sqs_to_status_update_handler" {
- event_source_arn = module.letter_status_updates_queue.sqs_queue_arn
- function_name = module.letter_status_update.function_arn
+ event_source_arn = module.amendments_queue.sqs_queue_arn
+ function_name = module.amendment_event_transformer.function_arn
batch_size = 10
maximum_batching_window_in_seconds = 1
scaling_config { maximum_concurrency = 10 }
depends_on = [
- module.letter_status_updates_queue, # ensures queue exists
- module.letter_status_update # ensures update handler exists
+ module.amendments_queue, # ensures queue exists
+ module.amendment_event_transformer # ensures update handler exists
]
}
diff --git a/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf b/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf
new file mode 100644
index 000000000..ffbf4bf8e
--- /dev/null
+++ b/infrastructure/terraform/components/api/lambda_event_source_mapping_letter_status_update.tf
@@ -0,0 +1,9 @@
+resource "aws_lambda_event_source_mapping" "amendment_event_transformer" {
+ event_source_arn = module.amendments_queue.sqs_queue_arn
+ function_name = module.amendment_event_transformer.function_name
+ batch_size = 10
+ maximum_batching_window_in_seconds = 5
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 683156a05..5cd0c2089 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -27,6 +27,8 @@ locals {
SUPPLIER_ID_HEADER = "nhsd-supplier-id",
APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
+ SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
+ EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
}
core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline"
diff --git a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf b/infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
similarity index 78%
rename from infrastructure/terraform/components/api/module_lambda_letter_status_update.tf
rename to infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
index b9d97fec5..2c4cbf82f 100644
--- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf
+++ b/infrastructure/terraform/components/api/module_lambda_amendment_event_transformer.tf
@@ -1,7 +1,7 @@
-module "letter_status_update" {
+module "amendment_event_transformer" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
- function_name = "letter_status_update"
+ function_name = "amendment_event_transformer"
description = "Processes letter status updates"
aws_account_id = var.aws_account_id
@@ -15,14 +15,14 @@ module "letter_status_update" {
kms_key_arn = module.kms.key_arn
iam_policy_document = {
- body = data.aws_iam_policy_document.letter_status_update.json
+ body = data.aws_iam_policy_document.amendment_event_transformer.json
}
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
function_code_base_path = local.aws_lambda_functions_dir_path
function_code_dir = "api-handler/dist"
function_include_common = true
- handler_function_name = "letterStatusUpdate"
+ handler_function_name = "transformAmendmentEvent"
runtime = "nodejs22.x"
memory = 512
timeout = 29
@@ -37,7 +37,7 @@ module "letter_status_update" {
lambda_env_vars = merge(local.common_lambda_env_vars, {})
}
-data "aws_iam_policy_document" "letter_status_update" {
+data "aws_iam_policy_document" "amendment_event_transformer" {
statement {
sid = "KMSPermissions"
effect = "Allow"
@@ -59,7 +59,6 @@ data "aws_iam_policy_document" "letter_status_update" {
actions = [
"dynamodb:GetItem",
"dynamodb:Query",
- "dynamodb:UpdateItem",
]
resources = [
@@ -79,7 +78,20 @@ data "aws_iam_policy_document" "letter_status_update" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.amendments_queue.sqs_queue_arn
+ ]
+ }
+
+ statement {
+ sid = "AllowSNSPublish"
+ effect = "Allow"
+
+ actions = [
+ "sns:Publish"
+ ]
+
+ resources = [
+ module.eventsub.sns_topic.arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
index d10e8f240..0298e0887 100644
--- a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
+++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf
@@ -35,7 +35,7 @@ module "patch_letter" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = merge(local.common_lambda_env_vars, {
- QUEUE_URL = module.letter_status_updates_queue.sqs_queue_url
+ QUEUE_URL = module.amendments_queue.sqs_queue_url
})
}
@@ -64,7 +64,7 @@ data "aws_iam_policy_document" "patch_letter_lambda" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.amendments_queue.sqs_queue_arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_post_letters.tf b/infrastructure/terraform/components/api/module_lambda_post_letters.tf
index 79b3b3f0b..55865da34 100644
--- a/infrastructure/terraform/components/api/module_lambda_post_letters.tf
+++ b/infrastructure/terraform/components/api/module_lambda_post_letters.tf
@@ -35,7 +35,7 @@ module "post_letters" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = merge(local.common_lambda_env_vars, {
- QUEUE_URL = module.letter_status_updates_queue.sqs_queue_url,
+ QUEUE_URL = module.amendments_queue.sqs_queue_url,
MAX_LIMIT = var.max_get_limit
})
}
@@ -65,7 +65,7 @@ data "aws_iam_policy_document" "post_letters" {
]
resources = [
- module.letter_status_updates_queue.sqs_queue_arn
+ module.amendments_queue.sqs_queue_arn
]
}
}
diff --git a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf b/infrastructure/terraform/components/api/module_sqs_amendments.tf
similarity index 72%
rename from infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf
rename to infrastructure/terraform/components/api/module_sqs_amendments.tf
index a604faaf5..264441e55 100644
--- a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf
+++ b/infrastructure/terraform/components/api/module_sqs_amendments.tf
@@ -1,8 +1,8 @@
-# Queue to transport update letter status messages
-module "letter_status_updates_queue" {
+# Queue to transport letter status amendment messages
+module "amendments_queue" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
- name = "letter_status_updates_queue"
+ name = "amendments_queue"
aws_account_id = var.aws_account_id
component = var.component
diff --git a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf
index 472afb81f..ad9d83946 100644
--- a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf
+++ b/infrastructure/terraform/components/api/module_sqs_letter_updates.tf
@@ -16,6 +16,7 @@ module "sqs_letter_updates" {
sqs_policy_overload = data.aws_iam_policy_document.letter_updates_queue_policy.json
}
+
data "aws_iam_policy_document" "letter_updates_queue_policy" {
version = "2012-10-17"
statement {
@@ -41,31 +42,4 @@ data "aws_iam_policy_document" "letter_updates_queue_policy" {
values = [module.eventsub.sns_topic.arn]
}
}
-
- statement {
- sid = "AllowSNSPermissions"
- effect = "Allow"
-
- principals {
- type = "Service"
- identifiers = ["sns.amazonaws.com"]
- }
-
- actions = [
- "sqs:SendMessage",
- "sqs:ListQueueTags",
- "sqs:GetQueueUrl",
- "sqs:GetQueueAttributes",
- ]
-
- resources = [
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-letter-updates-queue"
- ]
-
- condition {
- test = "ArnEquals"
- variable = "aws:SourceArn"
- values = [module.eventsub.sns_topic.arn]
- }
- }
}
diff --git a/infrastructure/terraform/components/api/moved.tf b/infrastructure/terraform/components/api/moved.tf
new file mode 100644
index 000000000..c0e2e7d2c
--- /dev/null
+++ b/infrastructure/terraform/components/api/moved.tf
@@ -0,0 +1,11 @@
+# Moved blocks to handle resource renames without destroy/recreate
+
+moved {
+ from = module.letter_status_updates_queue
+ to = module.amendments_queue
+}
+
+moved {
+ from = module.letter_status_update
+ to = module.amendment_event_transformer
+}
diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf
index 9c232c149..dfb3d63d6 100644
--- a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf
+++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf
@@ -2,4 +2,9 @@ resource "aws_sns_topic_subscription" "eventsub_sqs_letter_updates" {
topic_arn = module.eventsub.sns_topic.arn
protocol = "sqs"
endpoint = module.sqs_letter_updates.sqs_queue_arn
+
+ filter_policy_scope = "MessageBody"
+ filter_policy = jsonencode({
+ type = [{ prefix = "uk.nhs.notify.supplier-api.letter" }]
+ })
}
diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_allocator.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_allocator.tf
new file mode 100644
index 000000000..b4cab0d96
--- /dev/null
+++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_allocator.tf
@@ -0,0 +1,11 @@
+resource "aws_sns_topic_subscription" "eventsub_sqs_supplier_allocator" {
+ # The supplier allocator queue will be introduced by another ticket. For now, route events directly to the letter updates queue.
+ topic_arn = module.eventsub.sns_topic.arn
+ protocol = "sqs"
+ endpoint = module.sqs_letter_updates.sqs_queue_arn
+
+ filter_policy_scope = "MessageBody"
+ filter_policy = jsonencode({
+ type = [{ prefix = "uk.nhs.notify.letter-rendering.letter-request.prepared" }]
+ })
+}
diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf
index 47928a960..2439d69f2 100644
--- a/infrastructure/terraform/components/api/variables.tf
+++ b/infrastructure/terraform/components/api/variables.tf
@@ -167,7 +167,7 @@ variable "core_environment" {
variable "enable_event_cache" {
type = bool
description = "Enable caching of events to an S3 bucket"
- default = false
+ default = true
}
variable "enable_sns_delivery_logging" {
diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts
index 4c44ddbe6..193c1c077 100644
--- a/internal/datastore/src/__test__/letter-repository.test.ts
+++ b/internal/datastore/src/__test__/letter-repository.test.ts
@@ -14,10 +14,12 @@ function createLetter(
supplierId: string,
letterId: string,
status: Letter["status"] = "PENDING",
+ eventId?: string,
): InsertLetter {
const now = new Date().toISOString();
return {
id: letterId,
+ eventId,
supplierId,
specificationId: "specification1",
groupId: "group1",
@@ -168,6 +170,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "REJECTED",
reasonCode: "R01",
@@ -180,6 +183,7 @@ describe("LetterRepository", () => {
"letter1",
);
expect(updatedLetter.status).toBe("REJECTED");
+ expect(updatedLetter.previousStatus).toBe("PENDING");
expect(updatedLetter.reasonCode).toBe("R01");
expect(updatedLetter.reasonText).toBe("Reason text");
});
@@ -199,6 +203,7 @@ describe("LetterRepository", () => {
jest.setSystemTime(new Date(2020, 1, 2));
const letterDto: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -215,6 +220,7 @@ describe("LetterRepository", () => {
test("can't update a letter that does not exist", async () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -233,6 +239,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
@@ -241,6 +248,52 @@ describe("LetterRepository", () => {
).rejects.toThrow("Cannot do operations on a non-existent table");
});
+ test("does not update a letter if the same eventId is used", async () => {
+ const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
+ await letterRepository.putLetter(letter);
+
+ const duplicateUpdate: UpdateLetter = {
+ id: "letter1",
+ eventId: "event1",
+ supplierId: "supplier1",
+ status: "REJECTED",
+ reasonCode: "R01",
+ };
+ const result = await letterRepository.updateLetterStatus(duplicateUpdate);
+
+ expect(result).toBeUndefined();
+ const unchangedLetter = await letterRepository.getLetterById(
+ "supplier1",
+ "letter1",
+ );
+ expect(unchangedLetter.status).toBe("DELIVERED");
+ expect(unchangedLetter.eventId).toBe("event1");
+ expect(unchangedLetter.reasonCode).toBeUndefined();
+ });
+
+ test("updates a letter if a different eventId is used", async () => {
+ const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
+ await letterRepository.putLetter(letter);
+
+ const duplicateUpdate: UpdateLetter = {
+ id: "letter1",
+ eventId: "event2",
+ supplierId: "supplier1",
+ status: "REJECTED",
+ reasonCode: "R01",
+ };
+ const result = await letterRepository.updateLetterStatus(duplicateUpdate);
+
+ expect(result).toBeDefined();
+ const changedLetter = await letterRepository.getLetterById(
+ "supplier1",
+ "letter1",
+ );
+ expect(changedLetter.status).toBe("REJECTED");
+ expect(changedLetter.eventId).toBe("event2");
+ expect(changedLetter.reasonCode).toBe("R01");
+ });
+
test("should return a list of letters matching status", async () => {
await letterRepository.putLetter(createLetter("supplier1", "letter1"));
await letterRepository.putLetter(createLetter("supplier1", "letter2"));
@@ -278,6 +331,7 @@ describe("LetterRepository", () => {
const updateLetter: UpdateLetter = {
id: "letter1",
+ eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts
index f22868789..def7c1b36 100644
--- a/internal/datastore/src/letter-repository.ts
+++ b/internal/datastore/src/letter-repository.ts
@@ -7,6 +7,7 @@ import {
UpdateCommand,
UpdateCommandOutput,
} from "@aws-sdk/lib-dynamodb";
+import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { Logger } from "pino";
import { z } from "zod";
import {
@@ -163,32 +164,16 @@ export class LetterRepository {
};
}
- async updateLetterStatus(letterToUpdate: UpdateLetter): Promise {
+ async updateLetterStatus(
+ letterToUpdate: UpdateLetter,
+ ): Promise {
this.log.debug(
`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
);
let result: UpdateCommandOutput;
try {
- let updateExpression =
- "set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl";
- const expressionAttributeValues: Record = {
- ":status": letterToUpdate.status,
- ":updatedAt": new Date().toISOString(),
- ":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
- ":ttl": Math.floor(
- Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
- ),
- };
-
- if (letterToUpdate.reasonCode) {
- updateExpression += ", reasonCode = :reasonCode";
- expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
- }
-
- if (letterToUpdate.reasonText) {
- updateExpression += ", reasonText = :reasonText";
- expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
- }
+ const { expressionAttributeValues, updateExpression } =
+ this.buildUpdateExpression(letterToUpdate);
result = await this.ddbClient.send(
new UpdateCommand({
@@ -198,31 +183,61 @@ export class LetterRepository {
supplierId: letterToUpdate.supplierId,
},
UpdateExpression: updateExpression,
- ConditionExpression: "attribute_exists(id)", // Ensure letter exists
+ ConditionExpression:
+ "attribute_exists(id) AND (attribute_not_exists(eventId) OR eventId <> :eventId)",
ExpressionAttributeNames: {
"#status": "status",
"#ttl": "ttl",
},
ExpressionAttributeValues: expressionAttributeValues,
ReturnValues: "ALL_NEW",
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}),
);
+
+ this.log.debug(
+ `Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
+ );
+ return LetterSchema.parse(result.Attributes);
} catch (error) {
- if (
- error instanceof Error &&
- error.name === "ConditionalCheckFailedException"
- ) {
+ if (error instanceof ConditionalCheckFailedException) {
+ if (error.Item?.eventId.S === letterToUpdate.eventId) {
+ this.log.warn(
+ `Skipping update for letter ${letterToUpdate.id}: eventId ${letterToUpdate.eventId} already processed`,
+ );
+ return undefined;
+ }
throw new Error(
`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`,
);
}
throw error;
}
+ }
- this.log.debug(
- `Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
- );
- return LetterSchema.parse(result.Attributes);
+ private buildUpdateExpression(letterToUpdate: UpdateLetter) {
+ let updateExpression = `set #status = :status, previousStatus = #status, updatedAt = :updatedAt, supplierStatus = :supplierStatus,
+ #ttl = :ttl, eventId = :eventId`;
+ const expressionAttributeValues: Record = {
+ ":status": letterToUpdate.status,
+ ":updatedAt": new Date().toISOString(),
+ ":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
+ ":ttl": Math.floor(
+ Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
+ ),
+ ":eventId": letterToUpdate.eventId,
+ };
+
+ if (letterToUpdate.reasonCode) {
+ updateExpression += ", reasonCode = :reasonCode";
+ expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
+ }
+
+ if (letterToUpdate.reasonText) {
+ updateExpression += ", reasonText = :reasonText";
+ expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
+ }
+ return { updateExpression, expressionAttributeValues };
}
async getLettersBySupplier(
diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts
index a0b9f719c..fd83e0403 100644
--- a/internal/datastore/src/types.ts
+++ b/internal/datastore/src/types.ts
@@ -42,9 +42,11 @@ export const LetterSchemaBase = z.object({
export const LetterSchema = LetterSchemaBase.extend({
supplierId: idRef(SupplierSchema, "id"),
+ eventId: z.string().optional(),
url: z.url(),
createdAt: z.string(),
updatedAt: z.string(),
+ previousStatus: LetterStatus.optional(),
supplierStatus: z.string().describe("Secondary index PK"),
supplierStatusSk: z.string().describe("Secondary index SK"),
ttl: z.int(),
@@ -67,6 +69,7 @@ export type InsertLetter = Omit<
>;
export type UpdateLetter = {
id: string;
+ eventId: string;
supplierId: string;
status: Letter["status"];
reasonCode?: string;
diff --git a/internal/events/jest.config.ts b/internal/events/jest.config.ts
index 84251001b..926706a37 100644
--- a/internal/events/jest.config.ts
+++ b/internal/events/jest.config.ts
@@ -24,7 +24,7 @@ export const baseJestConfig: Config = {
},
},
- coveragePathIgnorePatterns: ["/__tests__/"],
+ coveragePathIgnorePatterns: ["/src/index.ts$", "/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
diff --git a/internal/events/package.json b/internal/events/package.json
index 40cd8bfcd..4da8a4861 100644
--- a/internal/events/package.json
+++ b/internal/events/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
+ "@internal/datastore": "*",
"zod": "^4.1.11"
},
"description": "Schemas for NHS Notify Supplier API events",
@@ -50,5 +51,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
- "version": "1.0.11"
+ "version": "1.0.12"
}
diff --git a/internal/events/src/__tests__/version.test.ts b/internal/events/src/__tests__/version.test.ts
new file mode 100644
index 000000000..412eef4ce
--- /dev/null
+++ b/internal/events/src/__tests__/version.test.ts
@@ -0,0 +1,11 @@
+import { MAJOR_VERSION, VERSION } from "../version";
+
+describe("version exports", () => {
+ it("should export MAJOR_VERSION as the first segment of the version", () => {
+ expect(VERSION.startsWith(`${MAJOR_VERSION}.`)).toBeTruthy();
+ });
+
+ it("should have VERSION in semver format", () => {
+ expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
+ });
+});
diff --git a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts b/internal/events/src/events/__tests__/letter-mapper.test.ts
similarity index 94%
rename from lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts
rename to internal/events/src/events/__tests__/letter-mapper.test.ts
index 077d73792..c870dc91b 100644
--- a/lambdas/letter-updates-transformer/src/mappers/__tests__/letter-mapper.test.ts
+++ b/internal/events/src/events/__tests__/letter-mapper.test.ts
@@ -1,6 +1,6 @@
import { $LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { Letter } from "@internal/datastore";
-import mapLetterToCloudEvent from "../letter-mapper";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
describe("letter-mapper", () => {
it("maps a letter to a letter event", async () => {
diff --git a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts b/internal/events/src/events/letter-mapper.ts
similarity index 86%
rename from lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts
rename to internal/events/src/events/letter-mapper.ts
index f2f25a827..91f72988a 100644
--- a/lambdas/letter-updates-transformer/src/mappers/letter-mapper.ts
+++ b/internal/events/src/events/letter-mapper.ts
@@ -1,10 +1,11 @@
-import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { randomBytes, randomUUID } from "node:crypto";
import eventSchemaPackage from "@nhsdigital/nhs-notify-event-schemas-supplier-api/package.json";
-import { LetterForEventPub } from "../types";
+import { Letter } from "@internal/datastore";
+import { LetterEvent } from "./letter-events";
-export default function mapLetterToCloudEvent(
- letter: LetterForEventPub,
+// eslint-disable-next-line import-x/prefer-default-export
+export function mapLetterToCloudEvent(
+ letter: Letter,
source: string,
): LetterEvent {
const eventId = randomUUID();
diff --git a/internal/events/src/index.ts b/internal/events/src/index.ts
index 339ddcd64..6aecfa516 100644
--- a/internal/events/src/index.ts
+++ b/internal/events/src/index.ts
@@ -4,4 +4,5 @@ export { default as DomainBase } from "./domain/domain-base";
export * from "./events/event-envelope";
export * from "./events/letter-events";
export * from "./events/mi-events";
+export * from "./events/letter-mapper";
export * from "./version";
diff --git a/internal/events/tsconfig.json b/internal/events/tsconfig.json
index 167e805ad..ad730e287 100644
--- a/internal/events/tsconfig.json
+++ b/internal/events/tsconfig.json
@@ -3,8 +3,7 @@
"declaration": true,
"isolatedModules": true,
"module": "commonjs",
- "outDir": "dist",
- "resolveJsonModule": true
+ "outDir": "dist"
},
"exclude": [
"node_modules",
diff --git a/lambdas/api-handler/package.json b/lambdas/api-handler/package.json
index 5bdaf1914..6983976e6 100644
--- a/lambdas/api-handler/package.json
+++ b/lambdas/api-handler/package.json
@@ -2,13 +2,15 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/client-s3": "^3.925.0",
+ "@aws-sdk/client-sns": "^3.925.0",
"@aws-sdk/client-sqs": "^3.925.0",
"@aws-sdk/lib-dynamodb": "^3.925.0",
"@aws-sdk/s3-request-presigner": "^3.925.0",
"@internal/datastore": "*",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.6",
+ "aws-lambda": "^1.0.7",
"esbuild": "0.27.2",
"pino": "^10.3.0",
"zod": "^4.1.11"
diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts
index afbca1d82..6b52a3474 100644
--- a/lambdas/api-handler/src/config/__tests__/env.test.ts
+++ b/lambdas/api-handler/src/config/__tests__/env.test.ts
@@ -25,6 +25,8 @@ describe("lambdaEnv", () => {
process.env.DOWNLOAD_URL_TTL_SECONDS = "60";
process.env.MAX_LIMIT = "2500";
process.env.QUEUE_URL = "url";
+ process.env.EVENT_SOURCE = "supplier-api";
+ process.env.SNS_TOPIC_ARN = "sns-topic.arn";
const { envVars } = require("../env");
@@ -38,6 +40,8 @@ describe("lambdaEnv", () => {
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500,
QUEUE_URL: "url",
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns-topic.arn",
});
});
@@ -61,6 +65,8 @@ describe("lambdaEnv", () => {
process.env.LETTER_TTL_HOURS = "12960";
process.env.MI_TTL_HOURS = "2160";
process.env.DOWNLOAD_URL_TTL_SECONDS = "60";
+ process.env.EVENT_SOURCE = "supplier-api";
+ process.env.SNS_TOPIC_ARN = "sns-topic.arn";
const { envVars } = require("../env");
@@ -73,6 +79,8 @@ describe("lambdaEnv", () => {
MI_TTL_HOURS: 2160,
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: undefined,
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns-topic.arn",
});
});
});
diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts
index 1a64998f4..ff4a1020f 100644
--- a/lambdas/api-handler/src/config/deps.ts
+++ b/lambdas/api-handler/src/config/deps.ts
@@ -2,6 +2,7 @@ import { S3Client } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { SQSClient } from "@aws-sdk/client-sqs";
+import { SNSClient } from "@aws-sdk/client-sns";
import { Logger } from "pino";
import {
DBHealthcheck,
@@ -14,6 +15,7 @@ import { EnvVars, envVars } from "./env";
export type Deps = {
s3Client: S3Client;
sqsClient: SQSClient;
+ snsClient: SNSClient;
letterRepo: LetterRepository;
miRepo: MIRepository;
dbHealthcheck: DBHealthcheck;
@@ -62,6 +64,7 @@ export function createDependenciesContainer(): Deps {
return {
s3Client: new S3Client(),
sqsClient: new SQSClient(),
+ snsClient: new SNSClient(),
letterRepo: createLetterRepository(log, envVars),
miRepo: createMIRepository(log, envVars),
dbHealthcheck: createDBHealthcheck(envVars),
diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts
index 0a98f2b4d..be22d3182 100644
--- a/lambdas/api-handler/src/config/env.ts
+++ b/lambdas/api-handler/src/config/env.ts
@@ -11,6 +11,8 @@ const EnvVarsSchema = z.object({
MAX_LIMIT: z.coerce.number().int().optional(),
QUEUE_URL: z.coerce.string().optional(),
PINO_LOG_LEVEL: z.coerce.string().optional(),
+ EVENT_SOURCE: z.string(),
+ SNS_TOPIC_ARN: z.string(),
});
export type EnvVars = z.infer;
diff --git a/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts b/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts
new file mode 100644
index 000000000..cce00574c
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/__tests__/amendment-event-transformer.test.ts
@@ -0,0 +1,183 @@
+import { Context, SQSEvent, SQSRecord } from "aws-lambda";
+import { mockDeep } from "jest-mock-extended";
+import pino from "pino";
+import { SNSClient } from "@aws-sdk/client-sns";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import { Letter, LetterRepository } from "@internal/datastore/src";
+import { UpdateLetterCommand } from "../../contracts/letters";
+import { EnvVars } from "../../config/env";
+import { Deps } from "../../config/deps";
+import createTransformAmendmentEventHandler from "../amendment-event-transformer";
+
+// Make crypto return consistent values, since we"re calling it in both prod and test code and comparing the values
+const realCrypto = jest.requireActual("crypto");
+const randomBytes: Record = {
+ "8": realCrypto.randomBytes(8),
+ "16": realCrypto.randomBytes(16),
+};
+jest.mock("crypto", () => ({
+ randomUUID: () => "4616b2d9-b7a5-45aa-8523-fa7419626b69",
+ randomBytes: (size: number) => randomBytes[String(size)],
+}));
+
+const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => {
+ const records: Partial[] = updateLetterCommand.map((letter) => {
+ return {
+ messageId: `mid-${letter.id}`,
+ body: JSON.stringify(letter),
+ messageAttributes: {
+ CorrelationId: {
+ dataType: "String",
+ stringValue: `correlationId-${letter.id}`,
+ },
+ },
+ };
+ });
+
+ const event: Partial = {
+ Records: records as SQSRecord[],
+ };
+
+ return event as SQSEvent;
+};
+
+describe("createLetterStatusUpdateHandler", () => {
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ });
+
+ const mockedDeps: jest.Mocked = {
+ snsClient: { send: jest.fn() } as unknown as SNSClient,
+ letterRepo: {
+ getLetterById: jest.fn(),
+ } as unknown as LetterRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ EVENT_SOURCE: "supplier-api",
+ SNS_TOPIC_ARN: "sns_topic.arn",
+ } as unknown as EnvVars,
+ } as Deps;
+
+ const letters: Letter[] = [
+ {
+ id: "id1",
+ supplierId: "s1",
+ status: "PENDING",
+ } as Letter,
+ {
+ id: "id2",
+ supplierId: "s2",
+ status: "PENDING",
+ } as Letter,
+ {
+ id: "id3",
+ supplierId: "s3",
+ status: "PENDING",
+ } as Letter,
+ ];
+
+ const updateLetterCommands: UpdateLetterCommand[] = [
+ {
+ ...letters[0],
+ status: "REJECTED",
+ reasonCode: "123",
+ reasonText: "Reason text",
+ },
+ { ...letters[1], status: "ACCEPTED" },
+ { ...letters[2], status: "DELIVERED" },
+ ];
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("processes letters successfully", async () => {
+ (mockedDeps.letterRepo.getLetterById as jest.Mock)
+ .mockResolvedValueOnce(letters[0])
+ .mockResolvedValueOnce(letters[1])
+ .mockResolvedValueOnce(letters[2]);
+
+ const context = mockDeep();
+ const callback = jest.fn();
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ await transformAmendmentEventHandler(
+ buildEvent(updateLetterCommands),
+ context,
+ callback,
+ );
+
+ for (let i = 0; i < 3; i++) {
+ expect(mockedDeps.snsClient.send).toHaveBeenNthCalledWith(
+ i + 1,
+ expect.objectContaining({
+ input: expect.objectContaining({
+ TopicArn: mockedDeps.env.SNS_TOPIC_ARN,
+ Message: JSON.stringify(
+ mapLetterToCloudEvent(
+ updateLetterCommands[i] as Letter,
+ mockedDeps.env.EVENT_SOURCE,
+ ),
+ ),
+ }),
+ }),
+ );
+ }
+ });
+
+ it("logs error if error thrown when updating", async () => {
+ const mockError = new Error("Update error");
+ (mockedDeps.snsClient.send as jest.Mock).mockRejectedValue(mockError);
+ (mockedDeps.letterRepo.getLetterById as jest.Mock).mockResolvedValueOnce(
+ letters[1],
+ );
+
+ const context = mockDeep();
+ const callback = jest.fn();
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ await transformAmendmentEventHandler(
+ buildEvent([updateLetterCommands[1]]),
+ context,
+ callback,
+ );
+
+ expect(mockedDeps.logger.error).toHaveBeenCalledWith({
+ description: "Error processing letter status update",
+ err: mockError,
+ messageId: "mid-id2",
+ correlationId: "correlationId-id2",
+ messageBody: '{"id":"id2","supplierId":"s2","status":"ACCEPTED"}',
+ });
+ });
+
+ it("returns batch update failures in the response", async () => {
+ (mockedDeps.letterRepo.getLetterById as jest.Mock)
+ .mockResolvedValueOnce(letters[0])
+ .mockResolvedValueOnce(letters[1])
+ .mockResolvedValueOnce(letters[2]);
+ (mockedDeps.snsClient.send as jest.Mock).mockResolvedValueOnce({});
+ (mockedDeps.snsClient.send as jest.Mock).mockRejectedValueOnce(
+ new Error("Update error"),
+ );
+ (mockedDeps.snsClient.send as jest.Mock).mockResolvedValueOnce({});
+
+ const transformAmendmentEventHandler =
+ createTransformAmendmentEventHandler(mockedDeps);
+ const sqsBatchResponse = await transformAmendmentEventHandler(
+ buildEvent(updateLetterCommands),
+ mockDeep(),
+ jest.fn(),
+ );
+
+ expect(sqsBatchResponse?.batchItemFailures).toEqual([
+ { itemIdentifier: "mid-id2" },
+ ]);
+ });
+});
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
deleted file mode 100644
index 7ec1866b9..000000000
--- a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Context, SQSEvent, SQSRecord } from "aws-lambda";
-import { mockDeep } from "jest-mock-extended";
-import { S3Client } from "@aws-sdk/client-s3";
-import pino from "pino";
-import { LetterRepository } from "@internal/datastore/src";
-import { UpdateLetterCommand } from "../../contracts/letters";
-import { EnvVars } from "../../config/env";
-import { Deps } from "../../config/deps";
-import createLetterStatusUpdateHandler from "../letter-status-update";
-
-const buildEvent = (updateLetterCommand: UpdateLetterCommand[]): SQSEvent => {
- const records: Partial[] = updateLetterCommand.map((letter) => {
- return {
- messageId: `mid-${letter.id}`,
- body: JSON.stringify(letter),
- messageAttributes: {
- CorrelationId: {
- dataType: "String",
- stringValue: `correlationId-${letter.id}`,
- },
- },
- };
- });
-
- const event: Partial = {
- Records: records as SQSRecord[],
- };
-
- return event as SQSEvent;
-};
-
-describe("createLetterStatusUpdateHandler", () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it("processes letters successfully", async () => {
- const updateLetterCommands: UpdateLetterCommand[] = [
- {
- id: "id1",
- status: "REJECTED",
- supplierId: "s1",
- reasonCode: "123",
- reasonText: "Reason text",
- },
- {
- id: "id2",
- supplierId: "s2",
- status: "ACCEPTED",
- },
- {
- id: "id3",
- supplierId: "s3",
- status: "DELIVERED",
- },
- ];
-
- const mockedDeps: jest.Mocked = {
- s3Client: {} as unknown as S3Client,
- letterRepo: {
- updateLetterStatus: jest
- .fn()
- .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: {
- SUPPLIER_ID_HEADER: "nhsd-supplier-id",
- APIM_CORRELATION_HEADER: "nhsd-correlation-id",
- LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME",
- LETTER_TTL_HOURS: 12_960,
- DOWNLOAD_URL_TTL_SECONDS: 60,
- MAX_LIMIT: 2500,
- QUEUE_URL: "SQS_URL",
- } as unknown as EnvVars,
- } as Deps;
-
- const context = mockDeep();
- const callback = jest.fn();
-
- const letterStatusUpdateHandler =
- createLetterStatusUpdateHandler(mockedDeps);
- await letterStatusUpdateHandler(
- buildEvent(updateLetterCommands),
- context,
- callback,
- );
-
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 1,
- updateLetterCommands[0],
- );
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 2,
- updateLetterCommands[1],
- );
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(
- 3,
- updateLetterCommands[2],
- );
- });
-
- it("logs error if error thrown when updating", async () => {
- const mockError = new Error("Update error");
-
- const mockedDeps: jest.Mocked = {
- s3Client: {} as unknown as S3Client,
- letterRepo: {
- updateLetterStatus: jest.fn().mockRejectedValue(mockError),
- } as unknown as LetterRepository,
- logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
- env: {
- SUPPLIER_ID_HEADER: "nhsd-supplier-id",
- APIM_CORRELATION_HEADER: "nhsd-correlation-id",
- LETTERS_TABLE_NAME: "LETTERS_TABLE_NAME",
- LETTER_TTL_HOURS: 12_960,
- DOWNLOAD_URL_TTL_SECONDS: 60,
- MAX_LIMIT: 2500,
- QUEUE_URL: "SQS_URL",
- } as unknown as EnvVars,
- } as Deps;
-
- const context = mockDeep();
- const callback = jest.fn();
-
- const updateLetterCommands: UpdateLetterCommand[] = [
- {
- id: "id1",
- status: "ACCEPTED",
- supplierId: "s1",
- },
- ];
-
- const letterStatusUpdateHandler =
- createLetterStatusUpdateHandler(mockedDeps);
- await letterStatusUpdateHandler(
- buildEvent(updateLetterCommands),
- context,
- callback,
- );
-
- expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledWith(
- updateLetterCommands[0],
- );
- expect(mockedDeps.logger.error).toHaveBeenCalledWith({
- description: "Error processing letter status update",
- err: mockError,
- messageId: "mid-id1",
- correlationId: "correlationId-id1",
- messageBody: '{"id":"id1","status":"ACCEPTED","supplierId":"s1"}',
- });
- });
-});
diff --git a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
new file mode 100644
index 000000000..eb06903ed
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts
@@ -0,0 +1,68 @@
+import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda";
+import { PublishCommand } from "@aws-sdk/client-sns";
+import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import {
+ UpdateLetterCommand,
+ UpdateLetterCommandSchema,
+} from "../contracts/letters";
+import { Deps } from "../config/deps";
+
+export default function createTransformAmendmentEventHandler(
+ deps: Deps,
+): SQSHandler {
+ return async (event: SQSEvent) => {
+ const batchItemFailures: SQSBatchItemFailure[] = [];
+
+ const tasks = event.Records.map(async (message) => {
+ try {
+ const updateLetterCommand: UpdateLetterCommand =
+ UpdateLetterCommandSchema.parse(JSON.parse(message.body));
+ const letter = await deps.letterRepo.getLetterById(
+ updateLetterCommand.supplierId,
+ updateLetterCommand.id,
+ );
+ letter.status = updateLetterCommand.status;
+ letter.reasonCode = updateLetterCommand.reasonCode;
+ letter.reasonText = updateLetterCommand.reasonText;
+
+ const letterEvent = mapLetterToCloudEvent(
+ letter,
+ deps.env.EVENT_SOURCE,
+ );
+ await deps.snsClient.send(
+ buildSnsCommand(letterEvent, deps.env.SNS_TOPIC_ARN),
+ );
+ deps.logger.info({
+ description: "Updated letter status",
+ letterId: updateLetterCommand.id,
+ messageId: message.messageId,
+ correlationId: message.messageAttributes.CorrelationId.stringValue,
+ });
+ } catch (error) {
+ deps.logger.error({
+ description: "Error processing letter status update",
+ err: error,
+ messageId: message.messageId,
+ correlationId: message.messageAttributes.CorrelationId.stringValue,
+ messageBody: message.body,
+ });
+ batchItemFailures.push({ itemIdentifier: message.messageId });
+ }
+ });
+
+ await Promise.all(tasks);
+
+ return { batchItemFailures };
+ };
+}
+
+function buildSnsCommand(
+ letterEvent: LetterEvent,
+ topicArn: string,
+): PublishCommand {
+ return new PublishCommand({
+ TopicArn: topicArn,
+ Message: JSON.stringify(letterEvent),
+ });
+}
diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts
deleted file mode 100644
index b49f8702a..000000000
--- a/lambdas/api-handler/src/handlers/letter-status-update.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { SQSEvent, SQSHandler } from "aws-lambda";
-import {
- UpdateLetterCommand,
- UpdateLetterCommandSchema,
-} from "../contracts/letters";
-import { Deps } from "../config/deps";
-import { mapToUpdateLetter } from "../mappers/letter-mapper";
-
-export default function createLetterStatusUpdateHandler(
- deps: Deps,
-): SQSHandler {
- return async (event: SQSEvent) => {
- const tasks = event.Records.map(async (message) => {
- try {
- const letterToUpdate: UpdateLetterCommand =
- UpdateLetterCommandSchema.parse(JSON.parse(message.body));
- await deps.letterRepo.updateLetterStatus(
- mapToUpdateLetter(letterToUpdate),
- );
- deps.logger.info({
- description: "Updated letter status",
- letterId: letterToUpdate.id,
- messageId: message.messageId,
- correlationId: message.messageAttributes.CorrelationId.stringValue,
- });
- } catch (error) {
- deps.logger.error({
- description: "Error processing letter status update",
- err: error,
- messageId: message.messageId,
- correlationId: message.messageAttributes.CorrelationId.stringValue,
- messageBody: message.body,
- });
- }
- });
-
- await Promise.all(tasks);
- };
-}
diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts
index 3a006e48d..573be050d 100644
--- a/lambdas/api-handler/src/index.ts
+++ b/lambdas/api-handler/src/index.ts
@@ -4,7 +4,7 @@ import createGetLetterDataHandler from "./handlers/get-letter-data";
import createGetLettersHandler from "./handlers/get-letters";
import createPatchLetterHandler from "./handlers/patch-letter";
import createPostLettersHandler from "./handlers/post-letters";
-import createLetterStatusUpdateHandler from "./handlers/letter-status-update";
+import createTransformAmendmentEventHandler from "./handlers/amendment-event-transformer";
import createPostMIHandler from "./handlers/post-mi";
import createGetStatusHandler from "./handlers/get-status";
@@ -14,7 +14,8 @@ export const getLetter = createGetLetterHandler(container);
export const getLetterData = createGetLetterDataHandler(container);
export const getLetters = createGetLettersHandler(container);
export const patchLetter = createPatchLetterHandler(container);
-export const letterStatusUpdate = createLetterStatusUpdateHandler(container);
+export const transformAmendmentEvent =
+ createTransformAmendmentEventHandler(container);
export const postLetters = createPostLettersHandler(container);
export const postMI = createPostMIHandler(container);
diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts
index c11d6d8c0..c31d61b34 100644
--- a/lambdas/api-handler/src/mappers/letter-mapper.ts
+++ b/lambdas/api-handler/src/mappers/letter-mapper.ts
@@ -1,4 +1,4 @@
-import { LetterBase, LetterStatus, UpdateLetter } from "@internal/datastore";
+import { LetterBase, LetterStatus } from "@internal/datastore";
import {
GetLetterResponse,
GetLetterResponseSchema,
@@ -68,22 +68,6 @@ export function mapToUpdateCommands(
}));
}
-// ---------------------------------------------
-// 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
// ---------------------------------------------
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 f00e62935..fd11de133 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
@@ -7,13 +7,12 @@ import {
KinesisStreamRecordPayload,
} from "aws-lambda";
import { mockDeep } from "jest-mock-extended";
-import { LetterBase } from "@internal/datastore";
+import { Letter } from "@internal/datastore";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
import createHandler from "../letter-updates-transformer";
import { Deps } from "../deps";
import { EnvVars } from "../env";
-import mapLetterToCloudEvent from "../mappers/letter-mapper";
import { LetterStatus } from "../../../api-handler/src/contracts/letters";
-import { LetterForEventPub } from "../types";
// Make crypto return consistent values, since we"re calling it in both prod and test code and comparing the values
const realCrypto = jest.requireActual("crypto");
@@ -171,7 +170,7 @@ describe("letter-updates-transformer Lambda", () => {
it("does not publish invalid letter data", async () => {
const handler = createHandler(mockedDeps);
const oldLetter = generateLetter("ACCEPTED");
- const newLetter = { id: oldLetter.id } as LetterForEventPub;
+ const newLetter = { id: oldLetter.id } as Letter;
const testData = generateKinesisEvent([
generateModifyRecord(oldLetter, newLetter),
@@ -324,7 +323,7 @@ describe("letter-updates-transformer Lambda", () => {
});
});
-function generateLetter(status: LetterStatus, id?: string): LetterForEventPub {
+function generateLetter(status: LetterStatus, id?: string): Letter {
return {
id: id || "1",
status,
@@ -337,14 +336,14 @@ function generateLetter(status: LetterStatus, id?: string): LetterForEventPub {
url: "https://example.com/letter.pdf",
source: "test-source",
subject: "test-source/subject-id",
+ supplierStatus: `supplier1#${status}`,
+ supplierStatusSk: "2025-12-10T11:12:54Z#1",
+ ttl: 1_234_567_890,
};
}
-function generateLetters(
- numLetters: number,
- status: LetterStatus,
-): LetterForEventPub[] {
- const letters: LetterForEventPub[] = Array.from({ length: numLetters });
+function generateLetters(numLetters: number, status: LetterStatus): Letter[] {
+ const letters: Letter[] = Array.from({ length: numLetters });
for (let i = 0; i < numLetters; i++) {
letters[i] = generateLetter(status, String(i + 1));
}
@@ -352,31 +351,34 @@ function generateLetters(
}
function generateModifyRecord(
- oldLetter: LetterForEventPub,
- newLetter: LetterForEventPub,
+ oldLetter: Letter,
+ newLetter: Letter,
): DynamoDBRecord {
- const oldImage = Object.fromEntries(
- Object.entries(oldLetter).map(([key, value]) => [key, { S: value }]),
- );
- const newImage = Object.fromEntries(
- Object.entries(newLetter).map(([key, value]) => [key, { S: value }]),
- );
+ const oldImage = buildStreamImage(oldLetter);
+ const newImage = buildStreamImage(newLetter);
return {
eventName: "MODIFY",
dynamodb: { OldImage: oldImage, NewImage: newImage },
};
}
-function generateInsertRecord(newLetter: LetterBase): DynamoDBRecord {
- const newImage = Object.fromEntries(
- Object.entries(newLetter).map(([key, value]) => [key, { S: value }]),
- );
+function generateInsertRecord(newLetter: Letter): DynamoDBRecord {
+ const newImage = buildStreamImage(newLetter);
return {
eventName: "INSERT",
dynamodb: { NewImage: newImage },
};
}
+function buildStreamImage(letter: Letter) {
+ return Object.fromEntries(
+ Object.entries(letter).map(([key, value]) => [
+ key,
+ typeof value === "number" ? { N: String(value) } : { S: value },
+ ]),
+ );
+}
+
function generateKinesisEvent(letterEvents: object[]): KinesisStreamEvent {
const records = letterEvents
.map((letter) => Buffer.from(JSON.stringify(letter)).toString("base64"))
diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
index 11b83c3b9..8ba2e1dd3 100644
--- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
+++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts
@@ -10,10 +10,10 @@ import {
PublishBatchRequestEntry,
} from "@aws-sdk/client-sns";
import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
+import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper";
+import { Letter, LetterSchema } from "@internal/datastore";
import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics";
-import mapLetterToCloudEvent from "./mappers/letter-mapper";
import { Deps } from "./deps";
-import { LetterForEventPub, LetterSchemaForEventPub } from "./types";
// SNS PublishBatchCommand supports up to 10 messages per batch
const BATCH_SIZE = 10;
@@ -140,9 +140,9 @@ function isChanged(record: DynamoDBRecord, property: string): boolean {
return oldValue?.S !== newValue?.S;
}
-function extractNewLetter(record: DynamoDBRecord): LetterForEventPub {
+function extractNewLetter(record: DynamoDBRecord): Letter {
const newImage = record.dynamodb?.NewImage!;
- return LetterSchemaForEventPub.parse(unmarshall(newImage as any));
+ return LetterSchema.parse(unmarshall(newImage as any));
}
function* generateBatches(events: LetterEvent[]) {
diff --git a/lambdas/letter-updates-transformer/src/types.ts b/lambdas/letter-updates-transformer/src/types.ts
deleted file mode 100644
index 34920991b..000000000
--- a/lambdas/letter-updates-transformer/src/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { LetterSchema } from "@internal/datastore";
-import { z } from "zod";
-
-export const LetterSchemaForEventPub = LetterSchema.omit({
- supplierStatus: true,
- supplierStatusSk: true,
- ttl: true,
-});
-
-export type LetterForEventPub = z.infer;
diff --git a/lambdas/letter-updates-transformer/tsconfig.json b/lambdas/letter-updates-transformer/tsconfig.json
index f3fa0970e..bb8177b74 100644
--- a/lambdas/letter-updates-transformer/tsconfig.json
+++ b/lambdas/letter-updates-transformer/tsconfig.json
@@ -1,7 +1,6 @@
{
"compilerOptions": {
- "esModuleInterop": true,
- "resolveJsonModule": true
+ "esModuleInterop": true
},
"extends": "../../tsconfig.base.json",
"include": [
diff --git a/lambdas/mi-updates-transformer/tsconfig.json b/lambdas/mi-updates-transformer/tsconfig.json
index f3fa0970e..bb8177b74 100644
--- a/lambdas/mi-updates-transformer/tsconfig.json
+++ b/lambdas/mi-updates-transformer/tsconfig.json
@@ -1,7 +1,6 @@
{
"compilerOptions": {
- "esModuleInterop": true,
- "resolveJsonModule": true
+ "esModuleInterop": true
},
"extends": "../../tsconfig.base.json",
"include": [
diff --git a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
index 73826cd53..a94942d06 100644
--- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
+++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
@@ -237,6 +237,7 @@ describe("createUpsertLetterHandler", () => {
const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock
.calls[0][0];
expect(firstArg.id).toBe("letter1");
+ expect(firstArg.eventId).toBe("7b9a03ca-342a-4150-b56b-989109c45613");
expect(firstArg.supplierId).toBe("supplier1");
expect(firstArg.specificationId).toBe("spec1");
expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf");
diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts
index 49232369b..67893ff22 100644
--- a/lambdas/upsert-letter/src/handler/upsert-handler.ts
+++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts
@@ -89,6 +89,7 @@ function mapToInsertLetter(
const now = new Date().toISOString();
return {
id: upsertRequest.data.domainId,
+ eventId: upsertRequest.id,
supplierId: supplier,
status: "PENDING",
specificationId: spec,
@@ -108,6 +109,7 @@ function mapToInsertLetter(
function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter {
return {
id: upsertRequest.data.domainId,
+ eventId: upsertRequest.id,
supplierId: upsertRequest.data.supplierId,
status: upsertRequest.data.status,
reasonCode: upsertRequest.data.reasonCode,
diff --git a/package-lock.json b/package-lock.json
index a63b758e2..d33bae5f3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -148,6 +148,7 @@
"license": "MIT",
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
+ "@internal/datastore": "*",
"zod": "^4.1.11"
},
"devDependencies": {
@@ -251,13 +252,15 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/client-s3": "^3.925.0",
+ "@aws-sdk/client-sns": "^3.925.0",
"@aws-sdk/client-sqs": "^3.925.0",
"@aws-sdk/lib-dynamodb": "^3.925.0",
"@aws-sdk/s3-request-presigner": "^3.925.0",
"@internal/datastore": "*",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.6",
+ "aws-lambda": "^1.0.7",
"esbuild": "0.27.2",
"pino": "^10.3.0",
"zod": "^4.1.11"
@@ -275,6 +278,8 @@
},
"lambdas/api-handler/node_modules/argparse": {
"version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
@@ -282,6 +287,8 @@
},
"lambdas/api-handler/node_modules/aws-lambda": {
"version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/aws-lambda/-/aws-lambda-1.0.7.tgz",
+ "integrity": "sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==",
"license": "MIT",
"dependencies": {
"aws-sdk": "^2.814.0",
@@ -295,10 +302,14 @@
},
"lambdas/api-handler/node_modules/commander": {
"version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz",
+ "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==",
"license": "MIT"
},
"lambdas/api-handler/node_modules/js-yaml": {
"version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
diff --git a/package.json b/package.json
index 4b2a1acd9..ca21d8c3f 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"overrides": {
"axios": "^1.13.5",
"fast-xml-parser": "^5.3.4",
+ "@isaacs/brace-expansion": "^5.0.1",
"pretty-format": {
"react-is": "19.0.0"
},
diff --git a/tests/e2e-tests/poetry.lock b/tests/e2e-tests/poetry.lock
index e57454e48..0764bc6d0 100644
--- a/tests/e2e-tests/poetry.lock
+++ b/tests/e2e-tests/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "annotated-types"
@@ -275,66 +275,61 @@ files = [
[[package]]
name = "cryptography"
-version = "46.0.3"
+version = "46.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
- {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
- {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
- {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
- {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
- {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
- {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
- {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
- {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
- {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
- {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
- {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
+ {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"},
+ {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"},
+ {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"},
+ {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"},
+ {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"},
+ {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"},
+ {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"},
+ {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"},
+ {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"},
+ {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"},
+ {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"},
+ {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"},
+ {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"},
+ {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"},
+ {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"},
+ {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"},
]
[package.dependencies]
@@ -347,7 +342,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
diff --git a/tsconfig.base.json b/tsconfig.base.json
index b340a8fa2..a0f424750 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -7,6 +7,7 @@
"module": "ES2020",
"moduleResolution": "node",
"noEmit": true,
+ "resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"