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 index b11740b8d..8073ffef6 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_config_ingress.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_config_ingress.tf @@ -1,12 +1,3 @@ - - - - - - - - - module "supplier_config_ingress" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" @@ -75,4 +66,17 @@ data "aws_iam_policy_document" "supplier_config_ingress_lambda" { 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/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/jest.config.ts b/lambdas/supplier-config-ingress/jest.config.ts index 2708bc41b..4a6681788 100644 --- a/lambdas/supplier-config-ingress/jest.config.ts +++ b/lambdas/supplier-config-ingress/jest.config.ts @@ -9,6 +9,9 @@ const baseJestConfig = { }, ], }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, diff --git a/lambdas/supplier-config-ingress/package.json b/lambdas/supplier-config-ingress/package.json index b3ad67bbd..61af0a0e8 100644 --- a/lambdas/supplier-config-ingress/package.json +++ b/lambdas/supplier-config-ingress/package.json @@ -1,7 +1,15 @@ { "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", - "esbuild": "^0.27.2" + "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, diff --git a/lambdas/supplier-config-ingress/src/__tests__/index.test.ts b/lambdas/supplier-config-ingress/src/__tests__/index.test.ts deleted file mode 100644 index 390ea3dc0..000000000 --- a/lambdas/supplier-config-ingress/src/__tests__/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SQSEvent } from "aws-lambda"; -import { supplierConfigHandler } from ".."; - -describe("supplierConfigHandler", () => { - it("returns an empty batchItemFailures list", async () => { - const event = { Records: [] } as unknown as SQSEvent; - - const result = await supplierConfigHandler(event); - - expect(result).toEqual({ batchItemFailures: [] }); - }); -}); 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 index 70af21c86..2bf12bf7b 100644 --- a/lambdas/supplier-config-ingress/src/index.ts +++ b/lambdas/supplier-config-ingress/src/index.ts @@ -1,9 +1,8 @@ -import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; +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 = async ( - _event: SQSEvent, -): Promise => { - // Implementation to be done under CCM-17379 - return { batchItemFailures: [] }; -}; +export const supplierConfigHandler = + createSupplierConfigIngressHandler(container); diff --git a/package-lock.json b/package-lock.json index f00237e9e..df8ca9b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,7 +116,7 @@ }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.18", + "version": "1.0.19", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", @@ -302,8 +302,47 @@ "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", - "esbuild": "^0.27.2" + "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": {