From e396a9d22918aea52e78fc20bb1eea9aa96f25c8 Mon Sep 17 00:00:00 2001 From: Steve Buxton Date: Wed, 7 Jan 2026 16:41:12 +0000 Subject: [PATCH] CCM-12999 Letter amendments queue --- .../terraform/components/api/README.md | 3 +- ...mbda_event_source_mapping_upsert_letter.tf | 2 +- .../api/module_lambda_allocation.tf | 57 +++++++ .../api/module_lambda_letter_status_update.tf | 2 +- .../api/module_lambda_patch_letter.tf | 2 +- .../api/module_lambda_post_letters.tf | 2 +- .../api/module_lambda_upsert_letter.tf | 2 +- ...ates.tf => module_sqs_amendments_queue.tf} | 5 +- ... => module_sqs_supplier_requests_queue.tf} | 7 +- ...ns_topic_subscription_allocation_lambda.tf | 13 ++ ...ubscription_eventsub_sqs_letter_updates.tf | 5 - ...atch_metric_alarm_sns_delivery_failures.tf | 2 +- .../terraform/modules/eventsub/outputs.tf | 4 +- .../{sns_topic.tf => sns_topic_event_bus.tf} | 4 +- .../modules/eventsub/sns_topic_policy.tf | 14 +- .../sns_topic_subscription_firehose.tf | 14 +- .../modules/eventsub/sns_topic_supplier.tf | 27 +++ internal/datastore/src/types.md | 3 + lambdas/allocation/.eslintignore | 1 + lambdas/allocation/.gitignore | 4 + lambdas/allocation/jest.config.ts | 60 +++++++ lambdas/allocation/package.json | 27 +++ .../src/__tests__/allocator.test.ts | 159 ++++++++++++++++++ lambdas/allocation/src/allocator.ts | 33 ++++ lambdas/allocation/src/deps.ts | 19 +++ lambdas/allocation/src/env.ts | 9 + lambdas/allocation/tsconfig.json | 8 + .../__tests__/letter-operations.test.ts | 3 + .../src/services/letter-operations.ts | 1 + package-lock.json | 49 +++++- package.json | 1 + sdk/_config.version.yml | 2 +- 32 files changed, 517 insertions(+), 27 deletions(-) create mode 100644 infrastructure/terraform/components/api/module_lambda_allocation.tf rename infrastructure/terraform/components/api/{module_sqs_letter_updates.tf => module_sqs_amendments_queue.tf} (93%) rename infrastructure/terraform/components/api/{module_sqs_letter_status_updates.tf => module_sqs_supplier_requests_queue.tf} (71%) create mode 100644 infrastructure/terraform/components/api/sns_topic_subscription_allocation_lambda.tf delete mode 100644 infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf rename infrastructure/terraform/modules/eventsub/{sns_topic.tf => sns_topic_event_bus.tf} (95%) create mode 100644 infrastructure/terraform/modules/eventsub/sns_topic_supplier.tf create mode 100644 lambdas/allocation/.eslintignore create mode 100644 lambdas/allocation/.gitignore create mode 100644 lambdas/allocation/jest.config.ts create mode 100644 lambdas/allocation/package.json create mode 100644 lambdas/allocation/src/__tests__/allocator.test.ts create mode 100644 lambdas/allocation/src/allocator.ts create mode 100644 lambdas/allocation/src/deps.ts create mode 100644 lambdas/allocation/src/env.ts create mode 100644 lambdas/allocation/tsconfig.json diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 607e80e2..bcc6397a 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,6 +37,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| +| [allocation\_lambda](#module\_allocation\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/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 | @@ -47,7 +48,6 @@ No requirements. | [get\_status](#module\_get\_status) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/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.24/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.26/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 | | [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | @@ -55,6 +55,7 @@ No requirements. | [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a | | [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-sqs.zip | n/a | +| [supplier\_requests\_queue](#module\_supplier\_requests\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-ssl.zip | n/a | | [upsert\_letter](#module\_upsert\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a | ## Outputs diff --git a/infrastructure/terraform/components/api/lambda_event_source_mapping_upsert_letter.tf b/infrastructure/terraform/components/api/lambda_event_source_mapping_upsert_letter.tf index a592ea9e..4b2f68df 100644 --- a/infrastructure/terraform/components/api/lambda_event_source_mapping_upsert_letter.tf +++ b/infrastructure/terraform/components/api/lambda_event_source_mapping_upsert_letter.tf @@ -1,5 +1,5 @@ resource "aws_lambda_event_source_mapping" "upsert_letter" { - event_source_arn = module.sqs_letter_updates.sqs_queue_arn + event_source_arn = module.amendments_queue.sqs_queue_arn function_name = module.upsert_letter.function_name batch_size = 10 maximum_batching_window_in_seconds = 5 diff --git a/infrastructure/terraform/components/api/module_lambda_allocation.tf b/infrastructure/terraform/components/api/module_lambda_allocation.tf new file mode 100644 index 00000000..7f42cf0a --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_allocation.tf @@ -0,0 +1,57 @@ +module "allocation_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip" + + function_name = "allocate_supplier" + description = "Lambda function for allocating supplier" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.allocation_lambda.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 = "allocation/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 29 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + send_to_firehose = true + log_destination_arn = local.destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + QUEUE_URL = module.sqs_letter_updates.sqs_queue_url + } + + data "aws_iam_policy_document" "allocation_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + } +} diff --git a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf index 7539d1a6..194c04fe 100644 --- a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf +++ b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf @@ -80,7 +80,7 @@ data "aws_iam_policy_document" "letter_status_update" { ] resources = [ - module.letter_status_updates_queue.sqs_queue_arn + module.supplier_requests_queue.sqs_queue_arn ] } } diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf index dfc496e9..732d7b22 100644 --- a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf @@ -65,7 +65,7 @@ data "aws_iam_policy_document" "patch_letter_lambda" { ] resources = [ - module.letter_status_updates_queue.sqs_queue_arn + module.supplier_requests_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 8d60266f..c23a5ae5 100644 --- a/infrastructure/terraform/components/api/module_lambda_post_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_post_letters.tf @@ -66,7 +66,7 @@ data "aws_iam_policy_document" "post_letters" { ] resources = [ - module.letter_status_updates_queue.sqs_queue_arn + module.supplier_requests_queue.sqs_queue_arn ] } } diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index 2e1fb32a..55fda143 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -83,7 +83,7 @@ data "aws_iam_policy_document" "upsert_letter_lambda" { ] resources = [ - module.sqs_letter_updates.sqs_queue_arn + module.amendments_queue.sqs_queue_arn ] } } diff --git a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf b/infrastructure/terraform/components/api/module_sqs_amendments_queue.tf similarity index 93% rename from infrastructure/terraform/components/api/module_sqs_letter_updates.tf rename to infrastructure/terraform/components/api/module_sqs_amendments_queue.tf index 472afb81..211b8e58 100644 --- a/infrastructure/terraform/components/api/module_sqs_letter_updates.tf +++ b/infrastructure/terraform/components/api/module_sqs_amendments_queue.tf @@ -6,7 +6,10 @@ module "sqs_letter_updates" { environment = var.environment project = var.project region = var.region - name = "letter-updates" + name = "${local.csi}-amendments-queue" + + fifo_queue = true + content_based_deduplication = true sqs_kms_key_arn = module.kms.key_arn diff --git a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf b/infrastructure/terraform/components/api/module_sqs_supplier_requests_queue.tf similarity index 71% rename from infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf rename to infrastructure/terraform/components/api/module_sqs_supplier_requests_queue.tf index a604faaf..0fbdc1bc 100644 --- a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf +++ b/infrastructure/terraform/components/api/module_sqs_supplier_requests_queue.tf @@ -1,8 +1,8 @@ # Queue to transport update letter status messages -module "letter_status_updates_queue" { +module "supplier_requests_queue" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" - name = "letter_status_updates_queue" + name = "${local.csi}-supplier-requests-queue" aws_account_id = var.aws_account_id component = var.component @@ -10,6 +10,9 @@ module "letter_status_updates_queue" { project = var.project region = var.region + fifo_queue = true + content_based_deduplication = true + sqs_kms_key_arn = module.kms.key_arn create_dlq = true diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_allocation_lambda.tf b/infrastructure/terraform/components/api/sns_topic_subscription_allocation_lambda.tf new file mode 100644 index 00000000..ac5c6e64 --- /dev/null +++ b/infrastructure/terraform/components/api/sns_topic_subscription_allocation_lambda.tf @@ -0,0 +1,13 @@ +resource "aws_sns_topic_subscription" "allocation_lambda" { + topic_arn = aws_sns_topic.sns_topic_event_bus.arn + protocol = "lambda" + endpoint = module.allocation_lambda.function_arn +} + +resource "aws_lambda_permission" "allocation_lambda_sns" { + statement_id = "AllowExecutionFromSNS" + action = "lambda:InvokeFunction" + function_name = module.allocation_lambda.function_name + principal = "sns.amazonaws.com" + source_arn = aws_sns_topic.sns_topic_event_bus.arn +} 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 deleted file mode 100644 index 9c232c14..00000000 --- a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_letter_updates.tf +++ /dev/null @@ -1,5 +0,0 @@ -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 -} diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf index e8ef1249..f174026f 100644 --- a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf +++ b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf @@ -11,6 +11,6 @@ resource "aws_cloudwatch_metric_alarm" "sns_delivery_failures" { treat_missing_data = "notBreaching" dimensions = { - TopicName = aws_sns_topic.main.name + TopicName = aws_sns_topic.sns_topic_event_bus.name } } diff --git a/infrastructure/terraform/modules/eventsub/outputs.tf b/infrastructure/terraform/modules/eventsub/outputs.tf index e2ff3b38..c1cc0cc6 100644 --- a/infrastructure/terraform/modules/eventsub/outputs.tf +++ b/infrastructure/terraform/modules/eventsub/outputs.tf @@ -1,8 +1,8 @@ output "sns_topic" { description = "SNS Topic ARN and Name" value = { - arn = aws_sns_topic.main.arn - name = aws_sns_topic.main.name + arn = aws_sns_topic.sns_topic_event_bus.arn + name = aws_sns_topic.sns_topic_event_bus.name } } diff --git a/infrastructure/terraform/modules/eventsub/sns_topic.tf b/infrastructure/terraform/modules/eventsub/sns_topic_event_bus.tf similarity index 95% rename from infrastructure/terraform/modules/eventsub/sns_topic.tf rename to infrastructure/terraform/modules/eventsub/sns_topic_event_bus.tf index cc30db15..28299fe7 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_event_bus.tf @@ -1,5 +1,5 @@ -resource "aws_sns_topic" "main" { - name = local.csi +resource "aws_sns_topic" "sns_topic_event_bus" { + name = "${local.csi}-event-bus-events" kms_master_key_id = var.kms_key_arn application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf b/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf index a772e9e7..5548695c 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf @@ -1,5 +1,11 @@ -resource "aws_sns_topic_policy" "main" { - arn = aws_sns_topic.main.arn +resource "aws_sns_topic_policy" "sns_topic_event_bus" { + arn = aws_sns_topic.sns_topic_event_bus.arn + + policy = data.aws_iam_policy_document.sns_topic_policy.json +} + +resource "aws_sns_topic_policy" "sns_topic_supplier" { + arn = aws_sns_topic.sns_topic_event_bus.arn policy = data.aws_iam_policy_document.sns_topic_policy.json } @@ -29,7 +35,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { ] resources = [ - aws_sns_topic.main.arn, + aws_sns_topic.sns_topic_event_bus.arn, ] condition { @@ -57,7 +63,7 @@ data "aws_iam_policy_document" "sns_topic_policy" { } resources = [ - aws_sns_topic.main.arn, + aws_sns_topic.sns_topic_event_bus.arn, ] } } diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf index 42457f6d..9bb714a0 100644 --- a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf +++ b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf @@ -1,7 +1,17 @@ -resource "aws_sns_topic_subscription" "firehose" { +resource "aws_sns_topic_subscription" "sns_topic_event_bus_firehose" { count = var.enable_event_cache ? 1 : 0 - topic_arn = aws_sns_topic.main.arn + topic_arn = aws_sns_topic.sns_topic_event_bus.arn + protocol = "firehose" + subscription_role_arn = aws_iam_role.sns_role.arn + endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn + raw_message_delivery = var.enable_firehose_raw_message_delivery +} + +resource "aws_sns_topic_subscription" "sns_topic_supplier_firehose" { + count = var.enable_event_cache ? 1 : 0 + + topic_arn = aws_sns_topic.sns_topic_supplier.arn protocol = "firehose" subscription_role_arn = aws_iam_role.sns_role.arn endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_supplier.tf b/infrastructure/terraform/modules/eventsub/sns_topic_supplier.tf new file mode 100644 index 00000000..a91b6692 --- /dev/null +++ b/infrastructure/terraform/modules/eventsub/sns_topic_supplier.tf @@ -0,0 +1,27 @@ +resource "aws_sns_topic" "sns_topic_event_bus" { + name = "${local.csi}-supplier-events" + kms_master_key_id = var.kms_key_arn + + fifo_topic = true + content_based_deduplication = true + + application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + application_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + application_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + firehose_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + firehose_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + firehose_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + http_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + http_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + http_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + lambda_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + lambda_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + lambda_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null + + sqs_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + sqs_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null + sqs_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null +} diff --git a/internal/datastore/src/types.md b/internal/datastore/src/types.md index 89056843..70504c55 100644 --- a/internal/datastore/src/types.md +++ b/internal/datastore/src/types.md @@ -22,6 +22,9 @@ erDiagram string supplierStatus string supplierStatusSk number ttl "min: -9007199254740991, max: 9007199254740991" + string source + string subject + string billingRef } ``` diff --git a/lambdas/allocation/.eslintignore b/lambdas/allocation/.eslintignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/lambdas/allocation/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/lambdas/allocation/.gitignore b/lambdas/allocation/.gitignore new file mode 100644 index 00000000..9b19292a --- /dev/null +++ b/lambdas/allocation/.gitignore @@ -0,0 +1,4 @@ +.build +coverage +node_modules +dist diff --git a/lambdas/allocation/jest.config.ts b/lambdas/allocation/jest.config.ts new file mode 100644 index 00000000..f88e7277 --- /dev/null +++ b/lambdas/allocation/jest.config.ts @@ -0,0 +1,60 @@ +import type { Config } from "jest"; + +export const baseJestConfig: Config = { + preset: "ts-jest", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // The directory where Jest should output its coverage files + coverageDirectory: "./.reports/unit/coverage", + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + // Use this configuration option to add custom reporters to Jest + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + // The test environment that will be used for testing + testEnvironment: "jsdom", +}; + +const utilsJestConfig = { + ...baseJestConfig, + + testEnvironment: "node", + + coveragePathIgnorePatterns: [ + ...(baseJestConfig.coveragePathIgnorePatterns ?? []), + "zod-validators.ts", + ], +}; + +export default utilsJestConfig; diff --git a/lambdas/allocation/package.json b/lambdas/allocation/package.json new file mode 100644 index 00000000..4c9b09e8 --- /dev/null +++ b/lambdas/allocation/package.json @@ -0,0 +1,27 @@ +{ + "dependencies": { + "@aws-sdk/client-sqs": "^3.925.0", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-lambda": "^1.0.7", + "esbuild": "^0.25.11", + "pino": "^9.7.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "jest-mock-extended": "^4.0.0", + "typescript": "^5.9.3" + }, + "name": "nhs-notify-supplier-allocation", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/allocation/src/__tests__/allocator.test.ts b/lambdas/allocation/src/__tests__/allocator.test.ts new file mode 100644 index 00000000..619e7f02 --- /dev/null +++ b/lambdas/allocation/src/__tests__/allocator.test.ts @@ -0,0 +1,159 @@ +import { Context, SNSEvent, SNSEventRecord } from "aws-lambda"; +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { mockDeep } from "jest-mock-extended"; +import pino from "pino"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src"; +import createAllocator from "../allocator"; +import { Deps } from "../deps"; + +function createSNSEvent(records: SNSEventRecord[]): SNSEvent { + return { + Records: records, + }; +} + +function createSNSEventRecord(message: string): SNSEventRecord { + return { + Sns: { + Message: message, + } as SNSEventRecord["Sns"], + } as SNSEventRecord; +} + +function createLetterEvent(domainId: string): LetterEvent { + const now = new Date().toISOString(); + + return $LetterEvent.parse({ + data: { + domainId, + groupId: "client_template", + origin: { + domain: "letter-rendering", + event: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + source: "/data-plane/letter-rendering/prod/render-pdf", + subject: + "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/test", + }, + + specificationId: "spec-001", + billingRef: "billing-001", + status: "PENDING", + supplierId: "supplier-001", + }, + datacontenttype: "application/json", + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.PENDING.1.0.0.schema.json", + dataschemaversion: "1.0.0", + id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + plane: "data", + recordedtime: now, + severitynumber: 2, + severitytext: "INFO", + source: "/data-plane/supplier-api/prod/update-status", + specversion: "1.0", + subject: + "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479", + time: now, + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + type: "uk.nhs.notify.supplier-api.letter.PENDING.v1", + }); +} + +describe("allocator", () => { + const mockQueueUrl = + "https://sqs.eu-west-2.amazonaws.com/123456789012/test-queue.fifo"; + + let mockDeps: Deps; + + beforeEach(() => { + mockDeps = { + sqsClient: { send: jest.fn() } as unknown as SQSClient, + queueUrl: mockQueueUrl, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + }; + + jest.clearAllMocks(); + }); + + describe("createAllocator", () => { + it("should process a single SNS record and send message to SQS", async () => { + const letterEvent = createLetterEvent("id1"); + const snsEvent = createSNSEvent([ + createSNSEventRecord(JSON.stringify(letterEvent)), + ]); + + const handler = createAllocator(mockDeps); + await handler(snsEvent, mockDeep(), jest.fn()); + + expect(mockDeps.sqsClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + QueueUrl: mockQueueUrl, + MessageBody: JSON.stringify(letterEvent), + MessageGroupId: "id1", + }, + }), + ); + }); + + it("should process multiple SNS records and send messages to SQS", async () => { + const letterEvent1 = createLetterEvent("id1"); + const letterEvent2 = createLetterEvent("id2"); + const letterEvent3 = createLetterEvent("id3"); + + const snsEvent = createSNSEvent([ + createSNSEventRecord(JSON.stringify(letterEvent1)), + createSNSEventRecord(JSON.stringify(letterEvent2)), + createSNSEventRecord(JSON.stringify(letterEvent3)), + ]); + + const handler = createAllocator(mockDeps); + await handler(snsEvent, mockDeep(), jest.fn()); + + expect(mockDeps.sqsClient.send).toHaveBeenCalledTimes(3); + + expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: { + QueueUrl: mockQueueUrl, + MessageBody: JSON.stringify(letterEvent1), + MessageGroupId: "id1", + }, + }), + ); + expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + input: { + QueueUrl: mockQueueUrl, + MessageBody: JSON.stringify(letterEvent2), + MessageGroupId: "id2", + }, + }), + ); + expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + input: { + QueueUrl: mockQueueUrl, + MessageBody: JSON.stringify(letterEvent3), + MessageGroupId: "id3", + }, + }), + ); + }); + + it("should handle empty SNS event with no records", async () => { + const snsEvent = createSNSEvent([]); + + const handler = createAllocator(mockDeps); + await handler(snsEvent, mockDeep(), jest.fn()); + + expect(mockDeps.sqsClient.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lambdas/allocation/src/allocator.ts b/lambdas/allocation/src/allocator.ts new file mode 100644 index 00000000..ad131218 --- /dev/null +++ b/lambdas/allocation/src/allocator.ts @@ -0,0 +1,33 @@ +import { SNSEvent, SNSEventRecord, SNSHandler } from "aws-lambda"; +import { SendMessageCommand } from "@aws-sdk/client-sqs"; +import { + $LetterEvent, + LetterEvent, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src"; +import { Deps } from "./deps"; + +export default function createAllocator(deps: Deps): SNSHandler { + return async (event: SNSEvent): Promise => { + // Allocation will be done under a future ticket. For now, just place events on the queue, + // adding a message group ID to permit the use of a FIFO queue + const sqsCommands: SendMessageCommand[] = event.Records.map((record) => + extractLetterEvent(record), + ).map((letterEvent) => buildSendMessageCommand(letterEvent, deps.queueUrl)); + + for (const sqsCommand of sqsCommands) { + deps.sqsClient.send(sqsCommand); + } + }; +} + +function extractLetterEvent(record: SNSEventRecord): LetterEvent { + return $LetterEvent.parse(JSON.parse(record.Sns.Message)); +} + +function buildSendMessageCommand(letterEvent: LetterEvent, queueUrl: string) { + return new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: JSON.stringify(letterEvent), + MessageGroupId: letterEvent.data.domainId, + }); +} diff --git a/lambdas/allocation/src/deps.ts b/lambdas/allocation/src/deps.ts new file mode 100644 index 00000000..be2969a8 --- /dev/null +++ b/lambdas/allocation/src/deps.ts @@ -0,0 +1,19 @@ +import pino from "pino"; +import { SQSClient } from "@aws-sdk/client-sqs"; +import { envVars } from "./env"; + +export type Deps = { + sqsClient: SQSClient; + queueUrl: string; + logger: pino.Logger; +}; + +export function createDependenciesContainer(): Deps { + const log = pino(); + + return { + sqsClient: new SQSClient(), + queueUrl: envVars.QUEUE_URL, + logger: log, + }; +} diff --git a/lambdas/allocation/src/env.ts b/lambdas/allocation/src/env.ts new file mode 100644 index 00000000..ad8e5901 --- /dev/null +++ b/lambdas/allocation/src/env.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const EnvVarsSchema = z.object({ + QUEUE_URL: z.coerce.string(), +}); + +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); diff --git a/lambdas/allocation/tsconfig.json b/lambdas/allocation/tsconfig.json new file mode 100644 index 00000000..24902365 --- /dev/null +++ b/lambdas/allocation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": {}, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index c7cfd592..6b253e99 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -246,6 +246,7 @@ describe("enqueueLetterUpdateRequests function", () => { reasonCode: updateLetterCommands[0].reasonCode, reasonText: updateLetterCommands[0].reasonText, }), + MessageGroupId: updateLetterCommands[0].id, }, }), ); @@ -266,6 +267,7 @@ describe("enqueueLetterUpdateRequests function", () => { status: updateLetterCommands[1].status, supplierId: updateLetterCommands[1].supplierId, }), + MessageGroupId: updateLetterCommands[1].id, }, }), ); @@ -309,6 +311,7 @@ describe("enqueueLetterUpdateRequests function", () => { status: updateLetterCommands[1].status, supplierId: updateLetterCommands[1].supplierId, }), + MessageGroupId: updateLetterCommands[1].id, }, }), ); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 384d694a..06f6deda 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -95,6 +95,7 @@ export async function enqueueLetterUpdateRequests( CorrelationId: { DataType: "String", StringValue: correlationId }, }, MessageBody: JSON.stringify(request), + MessageGroupId: request.id, }); await deps.sqsClient.send(command); } catch (error) { diff --git a/package-lock.json b/package-lock.json index 06d503e0..8457fd72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@aws-sdk/client-sns": "^3.936.0", "@playwright/test": "^1.55.1", "ajv": "^8.17.1", + "aws-lambda": "^1.0.7", "js-yaml": "^4.1.0", "openapi-response-validator": "^12.1.3", "serve": "^14.2.4" @@ -124,7 +125,7 @@ }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", @@ -905,6 +906,48 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "lambdas/allocation": { + "name": "nhs-notify-supplier-allocation", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.858.0", + "@aws-sdk/lib-dynamodb": "^3.858.0", + "@internal/datastore": "*", + "@types/aws-lambda": "^8.10.148", + "esbuild": "^0.25.11", + "pino": "^9.7.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "jest-mock-extended": "^4.0.0", + "typescript": "^5.9.3" + } + }, + "lambdas/allocation/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, "lambdas/api-handler": { "name": "nhs-notify-supplier-api-handler", "version": "0.0.1", @@ -21551,6 +21594,10 @@ "resolved": "docs", "link": true }, + "node_modules/nhs-notify-supplier-allocation": { + "resolved": "lambdas/allocation", + "link": true + }, "node_modules/nhs-notify-supplier-api-handler": { "resolved": "lambdas/api-handler", "link": true diff --git a/package.json b/package.json index 5b0f309d..5668551d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@aws-sdk/client-sns": "^3.936.0", "@playwright/test": "^1.55.1", "ajv": "^8.17.1", + "aws-lambda": "^1.0.7", "js-yaml": "^4.1.0", "openapi-response-validator": "^12.1.3", "serve": "^14.2.4" diff --git a/sdk/_config.version.yml b/sdk/_config.version.yml index 3941841c..97a2610b 100644 --- a/sdk/_config.version.yml +++ b/sdk/_config.version.yml @@ -1 +1 @@ -version: 1.0.1-20251125.131623+3d60875 +version: 1.0.1-20260107.085531+1eecf9b