diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index bc0a48bf5..d225f3d24 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -82,7 +82,9 @@ No requirements.
| [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a |
| [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip | n/a |
| [sqs\_supplier\_allocator](#module\_sqs\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip | n/a |
+| [sqs\_supplier\_config](#module\_sqs\_supplier\_config) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip | n/a |
| [supplier\_allocator](#module\_supplier\_allocator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a |
+| [supplier\_config\_ingress](#module\_supplier\_config\_ingress) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.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 |
| [update\_letter\_queue](#module\_update\_letter\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a |
| [upsert\_letter](#module\_upsert\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.2/terraform-lambda.zip | n/a |
diff --git a/infrastructure/terraform/components/api/lambda_event_source_mapping_supplier_config_ingress.tf b/infrastructure/terraform/components/api/lambda_event_source_mapping_supplier_config_ingress.tf
new file mode 100644
index 000000000..891e90120
--- /dev/null
+++ b/infrastructure/terraform/components/api/lambda_event_source_mapping_supplier_config_ingress.tf
@@ -0,0 +1,9 @@
+resource "aws_lambda_event_source_mapping" "supplier_config_ingress" {
+ event_source_arn = module.sqs_supplier_config.sqs_queue_arn
+ function_name = module.supplier_config_ingress.function_name
+ batch_size = 10
+ maximum_batching_window_in_seconds = 5
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/api/locals_alarms.tf b/infrastructure/terraform/components/api/locals_alarms.tf
index 873c6b38a..ee5248877 100644
--- a/infrastructure/terraform/components/api/locals_alarms.tf
+++ b/infrastructure/terraform/components/api/locals_alarms.tf
@@ -21,6 +21,7 @@ locals {
letter_updates_transformer = module.letter_updates_transformer.function_name
mi_updates_transformer = module.mi_updates_transformer.function_name
supplier_allocator = module.supplier_allocator.function_name
+ supplier_config_ingress = module.supplier_config_ingress.function_name
}
sqs_alarm_targets = {
@@ -28,5 +29,6 @@ locals {
amendments_queue = module.amendments_queue.sqs_queue_name
letter_status_updates_queue = module.letter_status_updates_queue.sqs_queue_name
sqs_supplier_allocator = module.sqs_supplier_allocator.sqs_queue_name
+ sqs_supplier_config = module.sqs_supplier_config.sqs_queue_name
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_config_ingress.tf b/infrastructure/terraform/components/api/module_lambda_supplier_config_ingress.tf
new file mode 100644
index 000000000..8073ffef6
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_supplier_config_ingress.tf
@@ -0,0 +1,82 @@
+module "supplier_config_ingress" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "supplier-config-ingress"
+ description = "Persist supplier config changes"
+
+ 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.supplier_config_ingress_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 = "supplier-config-ingress/dist"
+ function_include_common = true
+ handler_function_name = "supplierConfigHandler"
+ runtime = "nodejs22.x"
+ memory = 512
+ timeout = 29
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = merge(local.common_lambda_env_vars, {})
+}
+
+data "aws_iam_policy_document" "supplier_config_ingress_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowSQSRead"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes"
+ ]
+
+ resources = [
+ module.sqs_supplier_config.sqs_queue_arn
+ ]
+ }
+
+ statement {
+ sid = "AllowConfigDynamoDBWrite"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:UpdateItem",
+ ]
+
+ resources = [
+ aws_dynamodb_table.supplier-configuration.arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/api/module_sqs_supplier_config.tf b/infrastructure/terraform/components/api/module_sqs_supplier_config.tf
new file mode 100644
index 000000000..8adcd07cf
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_sqs_supplier_config.tf
@@ -0,0 +1,48 @@
+module "sqs_supplier_config" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "supplier-config"
+
+ sqs_kms_key_arn = module.kms.key_arn
+
+ visibility_timeout_seconds = 60
+
+ create_dlq = true
+ sqs_policy_overload = data.aws_iam_policy_document.supplier_config_queue_policy.json
+}
+
+data "aws_iam_policy_document" "supplier_config_queue_policy" {
+ version = "2012-10-17"
+
+ 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}-supplier-config-queue"
+ ]
+
+ condition {
+ test = "ArnEquals"
+ variable = "aws:SourceArn"
+ values = [module.eventsub.sns_topic.arn]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_config.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_config.tf
new file mode 100644
index 000000000..306cb4f2d
--- /dev/null
+++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventsub_sqs_supplier_config.tf
@@ -0,0 +1,11 @@
+resource "aws_sns_topic_subscription" "eventsub_sqs_supplier_config" {
+ topic_arn = module.eventsub.sns_topic.arn
+ protocol = "sqs"
+ endpoint = module.sqs_supplier_config.sqs_queue_arn
+ raw_message_delivery = true
+
+ filter_policy_scope = "MessageBody"
+ filter_policy = jsonencode({
+ type = [{ prefix = "uk.nhs.notify.supplier-config" }]
+ })
+}
diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts
index 6648b7fb6..cbee8b301 100644
--- a/internal/datastore/src/__test__/supplier-config-repository.test.ts
+++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts
@@ -1,4 +1,4 @@
-import { PutCommand } from "@aws-sdk/lib-dynamodb";
+import { GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import {
DBContext,
createTables,
@@ -6,11 +6,10 @@ import {
setupDynamoDBContainer,
} from "./db";
import { SupplierConfigRepository } from "../supplier-config-repository";
+import { SupplierConfigEntity } from "../types";
function createLetterVariantItem(variantId: string) {
return {
- pk: "ENTITY#letter-variant",
- sk: `ID#${variantId}`,
id: variantId,
name: `Variant ${variantId}`,
description: `Description for variant ${variantId}`,
@@ -18,6 +17,9 @@ function createLetterVariantItem(variantId: string) {
status: "PROD",
volumeGroupId: `group-${variantId}`,
packSpecificationIds: [`pack-spec-${variantId}`],
+ constraints: {
+ sheets: { value: 3, operator: "LESS_THAN" },
+ },
};
}
@@ -29,8 +31,6 @@ function createVolumeGroupItem(groupId: string, status = "PROD") {
.toISOString()
.split("T")[0]; // Ends in a day to ensure it's active based on end date. Tests can override this if needed.
return {
- pk: "ENTITY#volume-group",
- sk: `ID#${groupId}`,
id: groupId,
name: `Volume Group ${groupId}`,
description: `Description for volume group ${groupId}`,
@@ -46,8 +46,6 @@ function createSupplierAllocationItem(
supplier: string,
) {
return {
- pk: "ENTITY#supplier-allocation",
- sk: `ID#${allocationId}`,
id: allocationId,
status: "PROD",
volumeGroup: groupId,
@@ -58,8 +56,6 @@ function createSupplierAllocationItem(
function createSupplierItem(supplierId: string) {
return {
- pk: "ENTITY#supplier",
- sk: `ID#${supplierId}`,
id: supplierId,
name: `Supplier ${supplierId}`,
channelType: "LETTER",
@@ -68,6 +64,37 @@ function createSupplierItem(supplierId: string) {
};
}
+function createPackSpecificationItem(packSpecId: string) {
+ return {
+ id: packSpecId,
+ name: `Pack Specification ${packSpecId}`,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ version: 1,
+ billingId: `billing-${packSpecId}`,
+ postage: { id: "postageId", size: "STANDARD" },
+ status: "PROD",
+ };
+}
+
+function createSupplierPackItem(
+ supplierPackId: string,
+ packSpecId: string,
+ supplierId: string,
+) {
+ return {
+ id: supplierPackId,
+ packSpecificationId: packSpecId,
+ supplierId,
+ status: "PROD",
+ approval: "APPROVED",
+ };
+}
+
+function buildKey(entity: string, id: string) {
+ return { pk: `ENTITY#${entity}`, sk: `ID#${id}` };
+}
+
jest.setTimeout(30_000);
describe("SupplierConfigRepository", () => {
@@ -96,253 +123,340 @@ describe("SupplierConfigRepository", () => {
await dbContext.container.stop();
});
- test("getLetterVariant returns correct details for existing variant", async () => {
- const variantId = "variant-123";
- await dbContext.docClient.send(
- new PutCommand({
+ async function fetchEntity(key: { pk: string; sk: string }) {
+ return dbContext.docClient.send(
+ new GetCommand({
TableName: dbContext.config.supplierConfigTableName,
- Item: createLetterVariantItem(variantId),
+ Key: key,
}),
);
+ }
+
+ describe("getLetterVariant", () => {
+ it("returns correct details for existing variant", async () => {
+ const variantId = "variant-123";
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("letter-variant", variantId),
+ ...createLetterVariantItem(variantId),
+ },
+ }),
+ );
+
+ const result = await repository.getLetterVariant(variantId);
+
+ expect(result.id).toBe(variantId);
+ expect(result.name).toBe(`Variant ${variantId}`);
+ expect(result.description).toBe(`Description for variant ${variantId}`);
+ expect(result.type).toBe("STANDARD");
+ expect(result.status).toBe("PROD");
+ expect(result.volumeGroupId).toBe(`group-${variantId}`);
+ expect(result.packSpecificationIds).toEqual([`pack-spec-${variantId}`]);
+ });
- const result = await repository.getLetterVariant(variantId);
-
- expect(result.id).toBe(variantId);
- expect(result.name).toBe(`Variant ${variantId}`);
- expect(result.description).toBe(`Description for variant ${variantId}`);
- expect(result.type).toBe("STANDARD");
- expect(result.status).toBe("PROD");
- expect(result.volumeGroupId).toBe(`group-${variantId}`);
- expect(result.packSpecificationIds).toEqual([`pack-spec-${variantId}`]);
- });
-
- test("getLetterVariant throws error for non-existent variant", async () => {
- const variantId = "non-existent-variant";
-
- await expect(repository.getLetterVariant(variantId)).rejects.toThrow(
- `No letter variant details found for id ${variantId}`,
- );
- });
-
- test("getVolumeGroup returns correct details for existing group", async () => {
- const groupId = "group-123";
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: createVolumeGroupItem(groupId),
- }),
- );
-
- const result = await repository.getVolumeGroup(groupId);
-
- expect(result.id).toBe(groupId);
- expect(result.name).toBe(`Volume Group ${groupId}`);
- expect(result.description).toBe(`Description for volume group ${groupId}`);
- expect(result.status).toBe("PROD");
- expect(new Date(result.startDate).getTime()).toBeLessThan(Date.now());
- expect(new Date(result.endDate!).getTime()).toBeGreaterThan(Date.now());
- });
-
- test("getVolumeGroup throws error for non-existent group", async () => {
- const groupId = "non-existent-group";
+ it("throws error for non-existent variant", async () => {
+ const variantId = "non-existent-variant";
- await expect(repository.getVolumeGroup(groupId)).rejects.toThrow(
- `No volume group details found for id ${groupId}`,
- );
+ await expect(repository.getLetterVariant(variantId)).rejects.toThrow(
+ `No letter variant details found for id ${variantId}`,
+ );
+ });
});
- test("getSupplierAllocationsForVolumeGroup returns correct allocations", async () => {
- const allocationId = "allocation-123";
- const groupId = "group-123";
- const supplierId = "supplier-123";
-
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: createSupplierAllocationItem(allocationId, groupId, supplierId),
- }),
- );
+ describe("getVolumeGroup", () => {
+ it("returns correct details for existing group", async () => {
+ const groupId = "group-123";
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("volume-group", groupId),
+ ...createVolumeGroupItem(groupId),
+ },
+ }),
+ );
+
+ const result = await repository.getVolumeGroup(groupId);
+
+ expect(result.id).toBe(groupId);
+ expect(result.name).toBe(`Volume Group ${groupId}`);
+ expect(result.description).toBe(
+ `Description for volume group ${groupId}`,
+ );
+ expect(result.status).toBe("PROD");
+ expect(new Date(result.startDate).getTime()).toBeLessThan(Date.now());
+ expect(new Date(result.endDate!).getTime()).toBeGreaterThan(Date.now());
+ });
- const result =
- await repository.getSupplierAllocationsForVolumeGroup(groupId);
+ it("throws error for non-existent group", async () => {
+ const groupId = "non-existent-group";
- expect(result).toEqual([
- {
- id: allocationId,
- status: "PROD",
- volumeGroup: groupId,
- supplier: supplierId,
- allocationPercentage: 50,
- },
- ]);
+ await expect(repository.getVolumeGroup(groupId)).rejects.toThrow(
+ `No volume group details found for id ${groupId}`,
+ );
+ });
});
- test("getSupplierAllocationsForVolumeGroup returns multiple allocations", async () => {
- const allocationId1 = "allocation-123";
- const allocationId2 = "allocation-456";
- const groupId = "group-123";
- const supplierId1 = "supplier-123";
- const supplierId2 = "supplier-456";
-
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: createSupplierAllocationItem(allocationId1, groupId, supplierId1),
- }),
- );
-
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: createSupplierAllocationItem(allocationId2, groupId, supplierId2),
- }),
- );
-
- const result =
- await repository.getSupplierAllocationsForVolumeGroup(groupId);
-
- // order of allocations should not matter, just that both are present
- expect(result).toHaveLength(2);
- expect(result).toEqual(
- expect.arrayContaining([
- {
- id: allocationId1,
- status: "PROD",
- volumeGroup: groupId,
- supplier: supplierId1,
- allocationPercentage: 50,
- },
+ describe("getSupplierAllocationsForVolumeGroup", () => {
+ it("returns correct allocations", async () => {
+ const allocationId = "allocation-123";
+ const groupId = "group-123";
+ const supplierId = "supplier-123";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("supplier-allocation", allocationId),
+ ...createSupplierAllocationItem(allocationId, groupId, supplierId),
+ },
+ }),
+ );
+
+ const result =
+ await repository.getSupplierAllocationsForVolumeGroup(groupId);
+
+ expect(result).toEqual([
{
- id: allocationId2,
+ id: allocationId,
status: "PROD",
volumeGroup: groupId,
- supplier: supplierId2,
+ supplier: supplierId,
allocationPercentage: 50,
},
- ]),
- );
- });
+ ]);
+ });
- test("getSupplierAllocationsForVolumeGroup throws error for non-existent group", async () => {
- const groupId = "non-existent-group";
+ it("returns multiple allocations", async () => {
+ const allocationId1 = "allocation-123";
+ const allocationId2 = "allocation-456";
+ const groupId = "group-123";
+ const supplierId1 = "supplier-123";
+ const supplierId2 = "supplier-456";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("supplier-allocation", allocationId1),
+ ...createSupplierAllocationItem(
+ allocationId1,
+ groupId,
+ supplierId1,
+ ),
+ },
+ }),
+ );
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("supplier-allocation", allocationId2),
+ ...createSupplierAllocationItem(
+ allocationId2,
+ groupId,
+ supplierId2,
+ ),
+ },
+ }),
+ );
+
+ const result =
+ await repository.getSupplierAllocationsForVolumeGroup(groupId);
+
+ // order of allocations should not matter, just that both are present
+ expect(result).toHaveLength(2);
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ id: allocationId1,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier: supplierId1,
+ allocationPercentage: 50,
+ },
+ {
+ id: allocationId2,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier: supplierId2,
+ allocationPercentage: 50,
+ },
+ ]),
+ );
+ });
- await expect(
- repository.getSupplierAllocationsForVolumeGroup(groupId),
- ).rejects.toThrow(
- `No active supplier allocations found for volume group id ${groupId}`,
- );
+ it("throws error for non-existent group", async () => {
+ const groupId = "non-existent-group";
+
+ await expect(
+ repository.getSupplierAllocationsForVolumeGroup(groupId),
+ ).rejects.toThrow(
+ `No active supplier allocations found for volume group id ${groupId}`,
+ );
+ });
});
- test("getSuppliersDetails returns correct supplier details", async () => {
- const supplierId = "supplier-123";
+ describe("getSuppliersDetails", () => {
+ it("returns correct supplier details", async () => {
+ const supplierId = "supplier-123";
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: createSupplierItem(supplierId),
- }),
- );
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("supplier", supplierId),
+ ...createSupplierItem(supplierId),
+ },
+ }),
+ );
- const result = await repository.getSuppliersDetails([supplierId]);
+ const result = await repository.getSuppliersDetails([supplierId]);
- expect(result).toEqual([
- {
- id: supplierId,
- name: `Supplier ${supplierId}`,
- channelType: "LETTER",
- dailyCapacity: 1000,
- status: "PROD",
- },
- ]);
- });
+ expect(result).toEqual([
+ {
+ id: supplierId,
+ name: `Supplier ${supplierId}`,
+ channelType: "LETTER",
+ dailyCapacity: 1000,
+ status: "PROD",
+ },
+ ]);
+ });
- test("getSuppliersDetails throws error for non-existent supplier", async () => {
- const supplierId = "non-existent-supplier";
+ it("throws error for non-existent supplier", async () => {
+ const supplierId = "non-existent-supplier";
- await expect(repository.getSuppliersDetails([supplierId])).rejects.toThrow(
- `Supplier with id ${supplierId} not found`,
- );
+ await expect(
+ repository.getSuppliersDetails([supplierId]),
+ ).rejects.toThrow(`Supplier with id ${supplierId} not found`);
+ });
});
- test("getSupplierPacksForPackSpecification returns correct supplier packs", async () => {
- const packSpecId = "pack-spec-123";
- const supplierId = "supplier-123";
- const supplierPackId = "supplier-pack-123";
-
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: {
- pk: "ENTITY#supplier-pack",
- sk: `ID#${supplierPackId}`,
+ describe("getSupplierPacksForPackSpecification", () => {
+ it("returns correct supplier packs", async () => {
+ const packSpecId = "pack-spec-123";
+ const supplierId = "supplier-123";
+ const supplierPackId = "supplier-pack-123";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("supplier-pack", supplierPackId),
+ ...createSupplierPackItem(supplierPackId, packSpecId, supplierId),
+ },
+ }),
+ );
+
+ const result =
+ await repository.getSupplierPacksForPackSpecification(packSpecId);
+ expect(result).toEqual([
+ {
+ approval: "APPROVED",
id: supplierPackId,
packSpecificationId: packSpecId,
supplierId,
status: "PROD",
- approval: "APPROVED",
},
- }),
- );
-
- const result =
- await repository.getSupplierPacksForPackSpecification(packSpecId);
- expect(result).toEqual([
- {
- approval: "APPROVED",
- id: supplierPackId,
- packSpecificationId: packSpecId,
- supplierId,
- status: "PROD",
- },
- ]);
- });
+ ]);
+ });
- test("getSupplierPacksForPackSpecification returns empty array for non-existent pack specification", async () => {
- const packSpecId = "non-existent-pack-spec";
- const result =
- await repository.getSupplierPacksForPackSpecification(packSpecId);
- expect(result).toEqual([]);
+ it("returns empty array for non-existent pack specification", async () => {
+ const packSpecId = "non-existent-pack-spec";
+ const result =
+ await repository.getSupplierPacksForPackSpecification(packSpecId);
+ expect(result).toEqual([]);
+ });
});
- test("getPackSpecification returns correct pack specification details", async () => {
- const packSpecId = "pack-spec-123";
+ describe("getPackSpecification", () => {
+ it("returns correct pack specification details", async () => {
+ const packSpecId = "pack-spec-123";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: {
+ ...buildKey("pack-specification", packSpecId),
+ ...createPackSpecificationItem(packSpecId),
+ },
+ }),
+ );
+
+ const result = await repository.getPackSpecification(packSpecId);
+ expect(result).toEqual({
+ billingId: `billing-${packSpecId}`,
+ createdAt: expect.any(String),
+ id: packSpecId,
+ name: `Pack Specification ${packSpecId}`,
+ postage: { id: "postageId", size: "STANDARD" },
+ updatedAt: expect.any(String),
+ version: 1,
+ status: "PROD",
+ });
+ });
- await dbContext.docClient.send(
- new PutCommand({
- TableName: dbContext.config.supplierConfigTableName,
- Item: {
- pk: "ENTITY#pack-specification",
- sk: `ID#${packSpecId}`,
- id: packSpecId,
- name: `Pack Specification ${packSpecId}`,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- version: 1,
- billingId: `billing-${packSpecId}`,
- postage: { id: "postageId", size: "STANDARD" },
- status: "PROD",
- },
- }),
- );
+ it("throws error for non-existent pack specification", async () => {
+ const packSpecId = "non-existent-pack-spec";
- const result = await repository.getPackSpecification(packSpecId);
- expect(result).toEqual({
- billingId: `billing-${packSpecId}`,
- createdAt: expect.any(String),
- id: packSpecId,
- name: `Pack Specification ${packSpecId}`,
- postage: { id: "postageId", size: "STANDARD" },
- updatedAt: expect.any(String),
- version: 1,
- status: "PROD",
+ await expect(repository.getPackSpecification(packSpecId)).rejects.toThrow(
+ `No pack specification found for id ${packSpecId}`,
+ );
});
});
- test("getPackSpecification throws error for non-existent pack specification", async () => {
- const packSpecId = "non-existent-pack-spec";
+ describe("upsertSupplierConfig", () => {
+ const entityItems: [SupplierConfigEntity, { id: string }][] = [
+ ["letter-variant", createLetterVariantItem("variant-123")],
+ ["pack-specification", createPackSpecificationItem("pack-spec-123")],
+ [
+ "supplier-allocation",
+ createSupplierAllocationItem(
+ "allocation-123",
+ "group-123",
+ "supplier-123",
+ ),
+ ],
+ [
+ "supplier-pack",
+ createSupplierPackItem(
+ "supplier-pack-123",
+ "pack-spec-123",
+ "supplier-123",
+ ),
+ ],
+ ["supplier", createSupplierItem("supplier-123")],
+ ["volume-group", createVolumeGroupItem("group-123")],
+ ];
+
+ it.each(entityItems)(
+ "creates the %s config if it does not exist",
+ async (entity, item) => {
+ const result = await repository.upsertSupplierConfig(entity, item);
+
+ expect(result).toBe("CREATED");
+
+ const fetched = await fetchEntity(buildKey(entity, item.id));
+ expect(fetched.Item).toEqual(expect.objectContaining(item));
+ },
+ );
+
+ it.each(entityItems)(
+ "updates the %s config if it already exists",
+ async (entity, item) => {
+ await repository.upsertSupplierConfig(entity, item);
- await expect(repository.getPackSpecification(packSpecId)).rejects.toThrow(
- `No pack specification found for id ${packSpecId}`,
+ const result = await repository.upsertSupplierConfig(entity, item);
+
+ expect(result).toBe("UPDATED");
+
+ const fetched = await fetchEntity(buildKey(entity, item.id));
+ expect(fetched.Item).toEqual(expect.objectContaining(item));
+ },
);
});
});
diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts
index 46794c0c1..a385b46f7 100644
--- a/internal/datastore/src/supplier-config-repository.ts
+++ b/internal/datastore/src/supplier-config-repository.ts
@@ -2,6 +2,7 @@ import {
DynamoDBDocumentClient,
GetCommand,
QueryCommand,
+ UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import {
$LetterVariant,
@@ -17,11 +18,14 @@ import {
SupplierPack,
VolumeGroup,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
+import { SupplierConfigEntity } from "./types";
export type SupplierConfigRepositoryConfig = {
supplierConfigTableName: string;
};
+const reservedWords = new Set(["name", "type", "status", "constraints"]);
+
export class SupplierConfigRepository {
constructor(
readonly ddbClient: DynamoDBDocumentClient,
@@ -141,4 +145,51 @@ export class SupplierConfigRepository {
}
return $PackSpecification.parse(result.Item);
}
+
+ async upsertSupplierConfig(
+ entity: SupplierConfigEntity,
+ supplierConfig: { id: string },
+ ): Promise<"UPDATED" | "CREATED"> {
+ const updateExpression =
+ SupplierConfigRepository.buildUpdateExpression(supplierConfig);
+
+ const result = await this.ddbClient.send(
+ new UpdateCommand({
+ TableName: this.config.supplierConfigTableName,
+ Key: { pk: `ENTITY#${entity}`, sk: `ID#${supplierConfig.id}` },
+ ...updateExpression,
+ ReturnValues: "ALL_OLD",
+ }),
+ );
+ return result.Attributes?.pk ? "UPDATED" : "CREATED";
+ }
+
+ static escapeReservedWord(key: string) {
+ return reservedWords.has(key) ? `#${key}` : key;
+ }
+
+ static buildUpdateExpression(fieldsToUpdate: Record) {
+ const expressionAttributeNames = Object.fromEntries(
+ Object.keys(fieldsToUpdate)
+ .filter((key) => reservedWords.has(key))
+ .map((key) => [`#${key}`, key]),
+ );
+
+ const expressionAttributeValues = Object.fromEntries(
+ Object.entries(fieldsToUpdate).map(([key, value]) => [`:${key}`, value]),
+ );
+
+ const updateExpression = `set ${Object.keys(fieldsToUpdate)
+ .map(
+ (key) =>
+ `${SupplierConfigRepository.escapeReservedWord(key)} = :${key}`,
+ )
+ .join(", ")}`;
+
+ return {
+ ExpressionAttributeNames: expressionAttributeNames,
+ ExpressionAttributeValues: expressionAttributeValues,
+ UpdateExpression: updateExpression,
+ };
+ }
}
diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts
index 53708cedf..d6a4b0be9 100644
--- a/internal/datastore/src/types.ts
+++ b/internal/datastore/src/types.ts
@@ -158,3 +158,14 @@ export const $DailyAllocation = z
});
export type DailyAllocation = z.infer;
+
+export const $SupplierConfigEntity = z.enum([
+ "letter-variant",
+ "volume-group",
+ "supplier-allocation",
+ "supplier",
+ "pack-specification",
+ "supplier-pack",
+]);
+
+export type SupplierConfigEntity = z.infer;
diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
index 39ae14c84..667a5634f 100644
--- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
+++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
@@ -7,10 +7,6 @@ import {
$LetterStatusChangeEvent,
LetterStatusChangeEvent,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
-import {
- SupplierConfigRepository,
- SupplierQuotasRepository,
-} from "@internal/datastore";
import createSupplierAllocatorHandler from "../allocate-handler";
import * as supplierConfig from "../../services/supplier-config";
import * as supplierQuotas from "../../services/supplier-quotas";
@@ -187,33 +183,11 @@ function setupDefaultMocks() {
describe("createSupplierAllocatorHandler", () => {
let mockSqsClient: jest.Mocked;
let mockedDeps: jest.Mocked;
- let mockedSupplierConfigRepo: jest.Mocked;
- let mockedSupplierQuotasRepo: jest.Mocked;
beforeEach(() => {
mockSqsClient = {
send: jest.fn(),
} as unknown as jest.Mocked;
- mockedSupplierConfigRepo = {
- ddbClient: {} as any,
- config: {} as any,
- getLetterVariant: jest.fn(),
- getVolumeGroup: jest.fn(),
- getSupplierAllocationsForVolumeGroup: jest.fn(),
- getSuppliersDetails: jest.fn(),
- getSupplierPacksForPackSpecification: jest.fn(),
- getPackSpecification: jest.fn(),
- };
-
- mockedSupplierQuotasRepo = {
- ddbClient: {} as any,
- config: {} as any,
- getOverallAllocation: jest.fn(),
- updateOverallAllocation: jest.fn(),
- getDailyAllocation: jest.fn(),
- updateDailyAllocation: jest.fn(),
- };
-
mockedDeps = {
logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger,
env: {
@@ -221,8 +195,8 @@ describe("createSupplierAllocatorHandler", () => {
SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable",
},
sqsClient: mockSqsClient,
- supplierConfigRepo: mockedSupplierConfigRepo,
- supplierQuotasRepo: mockedSupplierQuotasRepo,
+ supplierConfigRepo: {} as unknown as Deps["supplierConfigRepo"],
+ supplierQuotasRepo: {} as unknown as Deps["supplierQuotasRepo"],
};
jest.clearAllMocks();
});
diff --git a/lambdas/supplier-config-ingress/.gitignore b/lambdas/supplier-config-ingress/.gitignore
new file mode 100644
index 000000000..80323f7cf
--- /dev/null
+++ b/lambdas/supplier-config-ingress/.gitignore
@@ -0,0 +1,4 @@
+coverage
+node_modules
+dist
+.reports
diff --git a/lambdas/supplier-config-ingress/jest.config.ts b/lambdas/supplier-config-ingress/jest.config.ts
new file mode 100644
index 000000000..4a6681788
--- /dev/null
+++ b/lambdas/supplier-config-ingress/jest.config.ts
@@ -0,0 +1,58 @@
+const baseJestConfig = {
+ preset: "ts-jest",
+ extensionsToTreatAsEsm: [".ts"],
+ transform: {
+ "^.+\\.ts$": [
+ "ts-jest",
+ {
+ useESM: true,
+ },
+ ],
+ },
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
+
+ // 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__/"],
+ 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: "node",
+};
+
+export default baseJestConfig;
diff --git a/lambdas/supplier-config-ingress/package.json b/lambdas/supplier-config-ingress/package.json
new file mode 100644
index 000000000..61af0a0e8
--- /dev/null
+++ b/lambdas/supplier-config-ingress/package.json
@@ -0,0 +1,24 @@
+{
+ "dependencies": {
+ "@aws-sdk/client-dynamodb": "^3.858.0",
+ "@aws-sdk/lib-dynamodb": "^3.1008.0",
+ "@internal/datastore": "*",
+ "@internal/helpers": "^0.1.0",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0",
+ "@types/aws-lambda": "^8.10.148",
+ "aws-embedded-metrics": "^4.2.0",
+ "esbuild": "^0.27.2",
+ "pino": "^9.7.0",
+ "zod": "^4.1.11"
+ },
+ "name": "nhs-notify-supplier-api-supplier-config-ingress",
+ "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/supplier-config-ingress/src/__tests__/supplier-config-ingress-handler.test.ts b/lambdas/supplier-config-ingress/src/__tests__/supplier-config-ingress-handler.test.ts
new file mode 100644
index 000000000..32871c666
--- /dev/null
+++ b/lambdas/supplier-config-ingress/src/__tests__/supplier-config-ingress-handler.test.ts
@@ -0,0 +1,247 @@
+import type { SQSRecord } from "aws-lambda";
+import { Unit } from "aws-embedded-metrics";
+import { MetricStatus } from "@internal/helpers";
+import createSupplierConfigIngressHandler from "../handler/supplier-config-ingress-handler";
+import { Deps } from "../config/deps";
+
+function createSqsRecord(
+ data: Record,
+ type = "uk.nhs.notify.supplier-config.supplier",
+): SQSRecord {
+ return {
+ messageId: "msg-1",
+ body: JSON.stringify({ type, data }),
+ } as unknown as SQSRecord;
+}
+
+function createSupplierConfig(overrides: Record = {}) {
+ return {
+ id: "supplier-1",
+ name: "Supplier 1",
+ channelType: "LETTER",
+ dailyCapacity: 2000,
+ status: "PROD",
+ ...overrides,
+ };
+}
+
+function createSupplierPackConfig() {
+ return {
+ id: "supplier1-client1-campaign",
+ packSpecificationId: "client-1-campaign",
+ supplierId: "supplier1",
+ approval: "APPROVED",
+ status: "PROD",
+ };
+}
+
+describe("supplierConfigHandler", () => {
+ let mockDeps: Deps;
+ let handler: ReturnType;
+
+ beforeEach(() => {
+ mockDeps = {
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ } as unknown as Deps["logger"],
+ supplierConfigRepo: {
+ upsertSupplierConfig: jest.fn().mockResolvedValue("CREATED"),
+ } as unknown as Deps["supplierConfigRepo"],
+ env: { SUPPLIER_CONFIG_TABLE_NAME: "test-table" },
+ };
+ handler = createSupplierConfigIngressHandler(mockDeps);
+ });
+
+ it("returns an empty batchItemFailures list when there are no records", async () => {
+ const event = { Records: [] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ });
+
+ it("upserts supplier config from an SNS message in an SQS record", async () => {
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data);
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(
+ mockDeps.supplierConfigRepo.upsertSupplierConfig,
+ ).toHaveBeenCalledWith("supplier", data);
+ });
+
+ it("reports failed records in batchItemFailures", async () => {
+ (
+ mockDeps.supplierConfigRepo.upsertSupplierConfig as jest.Mock
+ ).mockRejectedValue(new Error("DynamoDB error"));
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data);
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: record.messageId }],
+ });
+ });
+
+ it("rejects a type field containing no dots", async () => {
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data, "look-no-dots");
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: record.messageId }],
+ });
+ expect(
+ mockDeps.supplierConfigRepo.upsertSupplierConfig,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("rejects a type field not matching the name of any entity", async () => {
+ const data = createSupplierConfig();
+ const record = createSqsRecord(
+ data,
+ "uk.nhs.notify.supplier-config.suppler",
+ );
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: record.messageId }],
+ });
+ expect(
+ mockDeps.supplierConfigRepo.upsertSupplierConfig,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("accepts a type field ending in a status and version", async () => {
+ const data = createSupplierPackConfig();
+ const record = createSqsRecord(
+ data,
+ "uk.nhs.notify.supplier-config.supplier-pack.prod.v1",
+ );
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(
+ mockDeps.supplierConfigRepo.upsertSupplierConfig,
+ ).toHaveBeenCalledWith("supplier-pack", data);
+ });
+
+ it("rejects an entity not matching the appropriate schema", async () => {
+ const invalidData = createSupplierConfig({ dailyCapacity: undefined });
+ const record = createSqsRecord(invalidData);
+ const event = { Records: [record] };
+
+ const result = await handler(event);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: record.messageId }],
+ });
+ expect(
+ mockDeps.supplierConfigRepo.upsertSupplierConfig,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("emits a success metric for a created config event", async () => {
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data);
+ const event = { Records: [record] };
+
+ await handler(event);
+
+ expect(mockDeps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entity: "supplier",
+ result: "CREATED",
+ [MetricStatus.Success]: 1,
+ _aws: expect.objectContaining({
+ CloudWatchMetrics: expect.arrayContaining([
+ expect.objectContaining({
+ Metrics: [
+ expect.objectContaining({
+ Name: MetricStatus.Success,
+ Value: 1,
+ Unit: Unit.Count,
+ }),
+ ],
+ }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it("emits a success metric for an updated config event", async () => {
+ (
+ mockDeps.supplierConfigRepo.upsertSupplierConfig as jest.Mock
+ ).mockResolvedValue("UPDATED");
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data);
+ const event = { Records: [record] };
+
+ await handler(event);
+
+ expect(mockDeps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entity: "supplier",
+ result: "UPDATED",
+ [MetricStatus.Success]: 1,
+ _aws: expect.objectContaining({
+ CloudWatchMetrics: expect.arrayContaining([
+ expect.objectContaining({
+ Metrics: [
+ expect.objectContaining({
+ Name: MetricStatus.Success,
+ Value: 1,
+ Unit: Unit.Count,
+ }),
+ ],
+ }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it("emits a failure metric for failed records", async () => {
+ (
+ mockDeps.supplierConfigRepo.upsertSupplierConfig as jest.Mock
+ ).mockRejectedValue(new Error("DynamoDB error"));
+ const data = createSupplierConfig();
+ const record = createSqsRecord(data);
+ const event = { Records: [record] };
+
+ await handler(event);
+
+ expect(mockDeps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entity: "supplier",
+ [MetricStatus.Failure]: 1,
+ _aws: expect.objectContaining({
+ CloudWatchMetrics: expect.arrayContaining([
+ expect.objectContaining({
+ Metrics: [
+ expect.objectContaining({
+ Name: MetricStatus.Failure,
+ Value: 1,
+ Unit: Unit.Count,
+ }),
+ ],
+ }),
+ ]),
+ }),
+ }),
+ );
+ });
+});
diff --git a/lambdas/supplier-config-ingress/src/config/deps.ts b/lambdas/supplier-config-ingress/src/config/deps.ts
new file mode 100644
index 000000000..ec8ab9c6e
--- /dev/null
+++ b/lambdas/supplier-config-ingress/src/config/deps.ts
@@ -0,0 +1,38 @@
+import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
+import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
+import { Logger } from "pino";
+import { createLogger } from "@internal/helpers";
+import { SupplierConfigRepository } from "@internal/datastore";
+import { EnvVars, envVars } from "./env";
+
+export type Deps = {
+ supplierConfigRepo: SupplierConfigRepository;
+ logger: Logger;
+ env: EnvVars;
+};
+
+function createDocumentClient(): DynamoDBDocumentClient {
+ const ddbClient = new DynamoDBClient({});
+ return DynamoDBDocumentClient.from(ddbClient);
+}
+
+function createSupplierConfigRepository(
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ envVars: EnvVars,
+): SupplierConfigRepository {
+ const config = {
+ supplierConfigTableName: envVars.SUPPLIER_CONFIG_TABLE_NAME,
+ };
+
+ return new SupplierConfigRepository(createDocumentClient(), config);
+}
+
+export function createDependenciesContainer(): Deps {
+ const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL });
+
+ return {
+ supplierConfigRepo: createSupplierConfigRepository(envVars),
+ logger: log,
+ env: envVars,
+ };
+}
diff --git a/lambdas/supplier-config-ingress/src/config/env.ts b/lambdas/supplier-config-ingress/src/config/env.ts
new file mode 100644
index 000000000..e037d73d2
--- /dev/null
+++ b/lambdas/supplier-config-ingress/src/config/env.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+const EnvVarsSchema = z.object({
+ SUPPLIER_CONFIG_TABLE_NAME: z.string(),
+ PINO_LOG_LEVEL: z.coerce.string().optional(),
+});
+
+export type EnvVars = z.infer;
+
+export const envVars = EnvVarsSchema.parse(process.env);
diff --git a/lambdas/supplier-config-ingress/src/handler/supplier-config-ingress-handler.ts b/lambdas/supplier-config-ingress/src/handler/supplier-config-ingress-handler.ts
new file mode 100644
index 000000000..00ce43bbb
--- /dev/null
+++ b/lambdas/supplier-config-ingress/src/handler/supplier-config-ingress-handler.ts
@@ -0,0 +1,136 @@
+import type { SQSBatchResponse, SQSEvent, SQSRecord } from "aws-lambda";
+import { z } from "zod/v4";
+import { Unit } from "aws-embedded-metrics";
+import {
+ $SupplierConfigEntity,
+ SupplierConfigEntity,
+ SupplierConfigRepository,
+} from "@internal/datastore";
+import {
+ $LetterVariant,
+ $PackSpecification,
+ $Supplier,
+ $SupplierAllocation,
+ $SupplierPack,
+ $VolumeGroup,
+} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
+import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers";
+import { Deps } from "../config/deps";
+
+const $EventEnvelope = z.object({
+ type: z.string(),
+ data: z.looseObject({ id: z.string() }),
+});
+
+const entitySchemas: Record> = {
+ "letter-variant": $LetterVariant as unknown as z.ZodType<{ id: string }>,
+ "volume-group": $VolumeGroup as unknown as z.ZodType<{ id: string }>,
+ "supplier-allocation": $SupplierAllocation as unknown as z.ZodType<{
+ id: string;
+ }>,
+ supplier: $Supplier as unknown as z.ZodType<{ id: string }>,
+ "pack-specification": $PackSpecification as unknown as z.ZodType<{
+ id: string;
+ }>,
+ "supplier-pack": $SupplierPack as unknown as z.ZodType<{ id: string }>,
+};
+
+type UpsertResult = Awaited<
+ ReturnType
+>;
+
+function extractEntityFromType(type: string) {
+ const elements = type.split(".");
+ return $SupplierConfigEntity.parse(
+ elements.length > 4 ? elements[4] : undefined,
+ );
+}
+
+function parseSupplierConfigFromRecord(record: SQSRecord): {
+ entity: SupplierConfigEntity;
+ config: { id: string };
+} {
+ const event = $EventEnvelope.parse(JSON.parse(record.body));
+
+ const entity = extractEntityFromType(event.type);
+ const config = entitySchemas[entity].parse(event.data);
+
+ return { entity, config };
+}
+
+function emitSuccessMetric(
+ logger: Deps["logger"],
+ entity: string,
+ result: UpsertResult,
+) {
+ const namespace = "supplier-config-ingress";
+ const dimensions: Record = { entity, result };
+ const metric: MetricEntry = {
+ key: MetricStatus.Success,
+ value: 1,
+ unit: Unit.Count,
+ };
+ logger.info(buildEMFObject(namespace, dimensions, metric));
+}
+
+function emitFailureMetric(logger: Deps["logger"], entity: string) {
+ const namespace = "supplier-config-ingress";
+ const dimensions: Record = { entity };
+ const metric: MetricEntry = {
+ key: MetricStatus.Failure,
+ value: 1,
+ unit: Unit.Count,
+ };
+ logger.info(buildEMFObject(namespace, dimensions, metric));
+}
+
+export default function createSupplierConfigIngressHandler(deps: Deps) {
+ const { logger, supplierConfigRepo } = deps;
+
+ return async (sqsEvent: SQSEvent): Promise => {
+ const batchItemFailures: { itemIdentifier: string }[] = [];
+ const failedEntities: string[] = [];
+
+ for (const record of sqsEvent.Records) {
+ let entity: string | undefined;
+ try {
+ logger.info(
+ { messageId: record.messageId, body: record.body },
+ "Processing record",
+ );
+ const parsed = parseSupplierConfigFromRecord(record);
+ entity = parsed.entity;
+
+ logger.info(
+ { entity, id: parsed.config.id },
+ "Processing supplier config upsert",
+ );
+
+ const result = await supplierConfigRepo.upsertSupplierConfig(
+ parsed.entity,
+ parsed.config,
+ );
+
+ emitSuccessMetric(logger, parsed.entity, result);
+
+ logger.info(
+ { entity, pk: parsed.config.id, result },
+ "Supplier config upserted",
+ );
+ } catch (error) {
+ logger.error(
+ { error, messageId: record.messageId },
+ "Failed to process supplier config record",
+ );
+ batchItemFailures.push({ itemIdentifier: record.messageId });
+ failedEntities.push(entity ?? "unknown");
+ }
+ }
+
+ for (const entity of failedEntities) {
+ emitFailureMetric(logger, entity);
+ }
+
+ return { batchItemFailures };
+ };
+}
diff --git a/lambdas/supplier-config-ingress/src/index.ts b/lambdas/supplier-config-ingress/src/index.ts
new file mode 100644
index 000000000..2bf12bf7b
--- /dev/null
+++ b/lambdas/supplier-config-ingress/src/index.ts
@@ -0,0 +1,8 @@
+import { createDependenciesContainer } from "./config/deps";
+import createSupplierConfigIngressHandler from "./handler/supplier-config-ingress-handler";
+
+const container = createDependenciesContainer();
+
+// eslint-disable-next-line import-x/prefer-default-export
+export const supplierConfigHandler =
+ createSupplierConfigIngressHandler(container);
diff --git a/lambdas/supplier-config-ingress/tsconfig.json b/lambdas/supplier-config-ingress/tsconfig.json
new file mode 100644
index 000000000..528c0c8b5
--- /dev/null
+++ b/lambdas/supplier-config-ingress/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "types": [
+ "jest",
+ "node"
+ ]
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 0551ec930..328b311d6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -714,6 +714,53 @@
"pino": "bin.js"
}
},
+ "lambdas/supplier-config-ingress": {
+ "name": "nhs-notify-supplier-api-supplier-config-ingress",
+ "version": "0.0.1",
+ "dependencies": {
+ "@aws-sdk/client-dynamodb": "^3.858.0",
+ "@aws-sdk/lib-dynamodb": "^3.1008.0",
+ "@internal/datastore": "*",
+ "@internal/helpers": "^0.1.0",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0",
+ "@types/aws-lambda": "^8.10.148",
+ "aws-embedded-metrics": "^4.2.0",
+ "esbuild": "^0.27.2",
+ "pino": "^9.7.0",
+ "zod": "^4.1.11"
+ }
+ },
+ "lambdas/supplier-config-ingress/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/supplier-config-ingress/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"lambdas/update-letter-queue": {
"name": "nhs-notify-supplier-api-update-letter-queue",
"version": "0.0.1",
@@ -17916,6 +17963,10 @@
"resolved": "lambdas/mi-updates-transformer",
"link": true
},
+ "node_modules/nhs-notify-supplier-api-supplier-config-ingress": {
+ "resolved": "lambdas/supplier-config-ingress",
+ "link": true
+ },
"node_modules/nhs-notify-supplier-api-suppliers-data-utility": {
"resolved": "scripts/utilities/supplier-data",
"link": true