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