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"