From 6e955bbc32bd7c9c9c2e0d1271df85f3ab35781c Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 18 Nov 2025 10:51:18 +0000 Subject: [PATCH 01/10] CCM-11506: Add schemas --- .../schemas/RoutingConfigCompleted/v1.json | 297 ++++++++++++++++++ .../schemas/RoutingConfigDeleted/v1.json | 297 ++++++++++++++++++ .../schemas/RoutingConfigDrafted/v1.json | 297 ++++++++++++++++++ .../scripts/generate-json-schemas.ts | 22 ++ packages/event-schemas/src/common.ts | 31 ++ packages/event-schemas/src/events/index.ts | 3 + .../src/events/routing-config-completed.ts | 28 ++ .../src/events/routing-config-deleted.ts | 28 ++ .../src/events/routing-config-drafted.ts | 28 ++ packages/event-schemas/src/routing-config.ts | 112 +++++++ packages/event-schemas/src/template.ts | 33 +- 11 files changed, 1144 insertions(+), 32 deletions(-) create mode 100644 packages/event-schemas/schemas/RoutingConfigCompleted/v1.json create mode 100644 packages/event-schemas/schemas/RoutingConfigDeleted/v1.json create mode 100644 packages/event-schemas/schemas/RoutingConfigDrafted/v1.json create mode 100644 packages/event-schemas/src/common.ts create mode 100644 packages/event-schemas/src/events/routing-config-completed.ts create mode 100644 packages/event-schemas/src/events/routing-config-deleted.ts create mode 100644 packages/event-schemas/src/events/routing-config-drafted.ts create mode 100644 packages/event-schemas/src/routing-config.ts diff --git a/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json b/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json new file mode 100644 index 000000000..2baafd61b --- /dev/null +++ b/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "description": "Unique ID for this event", + "type": "string", + "maxLength": 1000 + }, + "time": { + "description": "Time the event was generated", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z$" + }, + "type": { + "type": "string", + "const": "uk.nhs.notify.template-management.RoutingConfigCompleted.v1" + }, + "source": { + "description": "Source of the event", + "type": "string" + }, + "specversion": { + "description": "Version of the envelope event schema", + "type": "string", + "const": "1.0" + }, + "datacontenttype": { + "description": "Always application/json", + "type": "string", + "const": "application/json" + }, + "subject": { + "description": "Unique identifier for the item in the event data", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$" + }, + "dataschema": { + "type": "string", + "const": "https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json" + }, + "dataschemaversion": { + "type": "string", + "pattern": "^1\\..*" + }, + "plane": { + "type": "string", + "const": "control" + }, + "data": { + "$ref": "#/$defs/RoutingConfigCompletedEventData" + } + }, + "required": [ + "id", + "time", + "type", + "source", + "specversion", + "datacontenttype", + "subject", + "dataschema", + "dataschemaversion", + "plane", + "data" + ], + "$defs": { + "RoutingConfigCompletedEventData": { + "allOf": [ + { + "type": "object", + "properties": { + "clientId": { + "description": "The client that owns the routing config", + "type": "string" + }, + "campaignId": { + "description": "The campaign that is associated with the routing config", + "type": "string" + }, + "id": { + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "name": { + "description": "User-provided name identifying the routing config", + "type": "string" + }, + "defaultCascadeGroup": { + "description": "Default cascade group name", + "type": "string" + }, + "createdAt": { + "description": "Timestamp for when the routing config was created", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "cascade": { + "description": "Array defining the order of channels for the routing config and how they are configured", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeItem" + } + }, + "cascadeGroupOverrides": { + "description": "Config defining non-default cascade groups and the conditons under which they will be used", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeGroupOverride" + } + }, + "status": { + "description": "Routing config status", + "type": "string", + "enum": [ + "DELETED", + "DRAFT", + "COMPLETED" + ] + } + }, + "required": [ + "clientId", + "campaignId", + "id", + "name", + "defaultCascadeGroup", + "createdAt", + "cascade", + "cascadeGroupOverrides", + "status" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "COMPLETED" + ] + } + }, + "required": [ + "status" + ] + } + ] + }, + "CascadeItem": { + "type": "object", + "properties": { + "channel": { + "description": "Communication type for this cascade item", + "type": "string", + "enum": [ + "NHS_APP", + "EMAIL", + "SMS", + "LETTER" + ] + }, + "channelType": { + "description": "Channel type for this cascade item", + "type": "string", + "enum": [ + "primary", + "secondary" + ] + }, + "defaultTemplateId": { + "description": "Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "conditionalTemplates": { + "type": "array", + "items": { + "$ref": "#/$defs/ConditionalTemplate" + } + }, + "cascadeGroups": { + "description": "List of cascade groups that the cascade item will be included in", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "channel", + "channelType", + "cascadeGroups" + ] + }, + "ConditionalTemplate": { + "type": "object", + "properties": { + "language": { + "description": "Language override for the template", + "type": "string", + "enum": [ + "ar", + "bg", + "bn", + "de", + "el", + "en", + "es", + "fa", + "fr", + "gu", + "hi", + "hu", + "it", + "ku", + "lt", + "lv", + "ne", + "pa", + "pl", + "pt", + "ro", + "ru", + "sk", + "so", + "sq", + "ta", + "tr", + "ur", + "zh" + ] + }, + "accessibleFormat": { + "description": "Communication preference override for the template", + "type": "string", + "enum": [ + "x1" + ] + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "templateId": { + "description": "Unique identifier for the template", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + } + }, + "required": [ + "templateId" + ] + }, + "CascadeGroupOverride": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "accessibleFormat": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + }, + "$id": "https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json" +} diff --git a/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json b/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json new file mode 100644 index 000000000..b9314bfd8 --- /dev/null +++ b/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "description": "Unique ID for this event", + "type": "string", + "maxLength": 1000 + }, + "time": { + "description": "Time the event was generated", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z$" + }, + "type": { + "type": "string", + "const": "uk.nhs.notify.template-management.RoutingConfigDeleted.v1" + }, + "source": { + "description": "Source of the event", + "type": "string" + }, + "specversion": { + "description": "Version of the envelope event schema", + "type": "string", + "const": "1.0" + }, + "datacontenttype": { + "description": "Always application/json", + "type": "string", + "const": "application/json" + }, + "subject": { + "description": "Unique identifier for the item in the event data", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$" + }, + "dataschema": { + "type": "string", + "const": "https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json" + }, + "dataschemaversion": { + "type": "string", + "pattern": "^1\\..*" + }, + "plane": { + "type": "string", + "const": "control" + }, + "data": { + "$ref": "#/$defs/RoutingConfigDeletedEventData" + } + }, + "required": [ + "id", + "time", + "type", + "source", + "specversion", + "datacontenttype", + "subject", + "dataschema", + "dataschemaversion", + "plane", + "data" + ], + "$defs": { + "RoutingConfigDeletedEventData": { + "allOf": [ + { + "type": "object", + "properties": { + "clientId": { + "description": "The client that owns the routing config", + "type": "string" + }, + "campaignId": { + "description": "The campaign that is associated with the routing config", + "type": "string" + }, + "id": { + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "name": { + "description": "User-provided name identifying the routing config", + "type": "string" + }, + "defaultCascadeGroup": { + "description": "Default cascade group name", + "type": "string" + }, + "createdAt": { + "description": "Timestamp for when the routing config was created", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "cascade": { + "description": "Array defining the order of channels for the routing config and how they are configured", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeItem" + } + }, + "cascadeGroupOverrides": { + "description": "Config defining non-default cascade groups and the conditons under which they will be used", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeGroupOverride" + } + }, + "status": { + "description": "Routing config status", + "type": "string", + "enum": [ + "DELETED", + "DRAFT", + "COMPLETED" + ] + } + }, + "required": [ + "clientId", + "campaignId", + "id", + "name", + "defaultCascadeGroup", + "createdAt", + "cascade", + "cascadeGroupOverrides", + "status" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "DELETED" + ] + } + }, + "required": [ + "status" + ] + } + ] + }, + "CascadeItem": { + "type": "object", + "properties": { + "channel": { + "description": "Communication type for this cascade item", + "type": "string", + "enum": [ + "NHS_APP", + "EMAIL", + "SMS", + "LETTER" + ] + }, + "channelType": { + "description": "Channel type for this cascade item", + "type": "string", + "enum": [ + "primary", + "secondary" + ] + }, + "defaultTemplateId": { + "description": "Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "conditionalTemplates": { + "type": "array", + "items": { + "$ref": "#/$defs/ConditionalTemplate" + } + }, + "cascadeGroups": { + "description": "List of cascade groups that the cascade item will be included in", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "channel", + "channelType", + "cascadeGroups" + ] + }, + "ConditionalTemplate": { + "type": "object", + "properties": { + "language": { + "description": "Language override for the template", + "type": "string", + "enum": [ + "ar", + "bg", + "bn", + "de", + "el", + "en", + "es", + "fa", + "fr", + "gu", + "hi", + "hu", + "it", + "ku", + "lt", + "lv", + "ne", + "pa", + "pl", + "pt", + "ro", + "ru", + "sk", + "so", + "sq", + "ta", + "tr", + "ur", + "zh" + ] + }, + "accessibleFormat": { + "description": "Communication preference override for the template", + "type": "string", + "enum": [ + "x1" + ] + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "templateId": { + "description": "Unique identifier for the template", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + } + }, + "required": [ + "templateId" + ] + }, + "CascadeGroupOverride": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "accessibleFormat": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + }, + "$id": "https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json" +} diff --git a/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json b/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json new file mode 100644 index 000000000..b214ce743 --- /dev/null +++ b/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "description": "Unique ID for this event", + "type": "string", + "maxLength": 1000 + }, + "time": { + "description": "Time the event was generated", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z$" + }, + "type": { + "type": "string", + "const": "uk.nhs.notify.template-management.RoutingConfigDrafted.v1" + }, + "source": { + "description": "Source of the event", + "type": "string" + }, + "specversion": { + "description": "Version of the envelope event schema", + "type": "string", + "const": "1.0" + }, + "datacontenttype": { + "description": "Always application/json", + "type": "string", + "const": "application/json" + }, + "subject": { + "description": "Unique identifier for the item in the event data", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$" + }, + "dataschema": { + "type": "string", + "const": "https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json" + }, + "dataschemaversion": { + "type": "string", + "pattern": "^1\\..*" + }, + "plane": { + "type": "string", + "const": "control" + }, + "data": { + "$ref": "#/$defs/RoutingConfigDraftedEventData" + } + }, + "required": [ + "id", + "time", + "type", + "source", + "specversion", + "datacontenttype", + "subject", + "dataschema", + "dataschemaversion", + "plane", + "data" + ], + "$defs": { + "RoutingConfigDraftedEventData": { + "allOf": [ + { + "type": "object", + "properties": { + "clientId": { + "description": "The client that owns the routing config", + "type": "string" + }, + "campaignId": { + "description": "The campaign that is associated with the routing config", + "type": "string" + }, + "id": { + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "name": { + "description": "User-provided name identifying the routing config", + "type": "string" + }, + "defaultCascadeGroup": { + "description": "Default cascade group name", + "type": "string" + }, + "createdAt": { + "description": "Timestamp for when the routing config was created", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + }, + "cascade": { + "description": "Array defining the order of channels for the routing config and how they are configured", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeItem" + } + }, + "cascadeGroupOverrides": { + "description": "Config defining non-default cascade groups and the conditons under which they will be used", + "type": "array", + "items": { + "$ref": "#/$defs/CascadeGroupOverride" + } + }, + "status": { + "description": "Routing config status", + "type": "string", + "enum": [ + "DELETED", + "DRAFT", + "COMPLETED" + ] + } + }, + "required": [ + "clientId", + "campaignId", + "id", + "name", + "defaultCascadeGroup", + "createdAt", + "cascade", + "cascadeGroupOverrides", + "status" + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "DRAFT" + ] + } + }, + "required": [ + "status" + ] + } + ] + }, + "CascadeItem": { + "type": "object", + "properties": { + "channel": { + "description": "Communication type for this cascade item", + "type": "string", + "enum": [ + "NHS_APP", + "EMAIL", + "SMS", + "LETTER" + ] + }, + "channelType": { + "description": "Channel type for this cascade item", + "type": "string", + "enum": [ + "primary", + "secondary" + ] + }, + "defaultTemplateId": { + "description": "Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "conditionalTemplates": { + "type": "array", + "items": { + "$ref": "#/$defs/ConditionalTemplate" + } + }, + "cascadeGroups": { + "description": "List of cascade groups that the cascade item will be included in", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "channel", + "channelType", + "cascadeGroups" + ] + }, + "ConditionalTemplate": { + "type": "object", + "properties": { + "language": { + "description": "Language override for the template", + "type": "string", + "enum": [ + "ar", + "bg", + "bn", + "de", + "el", + "en", + "es", + "fa", + "fr", + "gu", + "hi", + "hu", + "it", + "ku", + "lt", + "lv", + "ne", + "pa", + "pl", + "pt", + "ro", + "ru", + "sk", + "so", + "sq", + "ta", + "tr", + "ur", + "zh" + ] + }, + "accessibleFormat": { + "description": "Communication preference override for the template", + "type": "string", + "enum": [ + "x1" + ] + }, + "supplierReferences": { + "description": "Supplier references that identify the template", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "templateId": { + "description": "Unique identifier for the template", + "type": "string", + "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$" + } + }, + "required": [ + "templateId" + ] + }, + "CascadeGroupOverride": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "accessibleFormat": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + }, + "$id": "https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json" +} diff --git a/packages/event-schemas/scripts/generate-json-schemas.ts b/packages/event-schemas/scripts/generate-json-schemas.ts index b280dd91c..d01ce11e2 100644 --- a/packages/event-schemas/scripts/generate-json-schemas.ts +++ b/packages/event-schemas/scripts/generate-json-schemas.ts @@ -6,6 +6,9 @@ import { $TemplateCompletedEventV1, $TemplateDeletedEventV1, $TemplateDraftedEventV1, + $RoutingConfigCompletedEventV1, + $RoutingConfigDeletedEventV1, + $RoutingConfigDraftedEventV1, } from '../src'; import { toJSONSchema, type ZodType } from 'zod'; import { JSONSchema } from 'zod/v4/core'; @@ -71,3 +74,22 @@ writeSchema( '1', 'https://notify.nhs.uk/events/schemas/TemplateDrafted/v1.json' ); + +writeSchema( + 'RoutingConfigCompleted', + $RoutingConfigCompletedEventV1, + '1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json' +); +writeSchema( + 'RoutingConfigDeleted', + $RoutingConfigDeletedEventV1, + '1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json' +); +writeSchema( + 'RoutingConfigDrafted', + $RoutingConfigDraftedEventV1, + '1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json' +); diff --git a/packages/event-schemas/src/common.ts b/packages/event-schemas/src/common.ts new file mode 100644 index 000000000..8add4b598 --- /dev/null +++ b/packages/event-schemas/src/common.ts @@ -0,0 +1,31 @@ +export const languages = [ + 'ar', + 'bg', + 'bn', + 'de', + 'el', + 'en', + 'es', + 'fa', + 'fr', + 'gu', + 'hi', + 'hu', + 'it', + 'ku', + 'lt', + 'lv', + 'ne', + 'pa', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'so', + 'sq', + 'ta', + 'tr', + 'ur', + 'zh', +]; diff --git a/packages/event-schemas/src/events/index.ts b/packages/event-schemas/src/events/index.ts index e9ddf0cf5..fe4096163 100644 --- a/packages/event-schemas/src/events/index.ts +++ b/packages/event-schemas/src/events/index.ts @@ -1,3 +1,6 @@ export * from './template-completed'; export * from './template-deleted'; export * from './template-drafted'; +export * from './routing-config-completed'; +export * from './routing-config-deleted'; +export * from './routing-config-drafted'; diff --git a/packages/event-schemas/src/events/routing-config-completed.ts b/packages/event-schemas/src/events/routing-config-completed.ts new file mode 100644 index 000000000..dde2897d8 --- /dev/null +++ b/packages/event-schemas/src/events/routing-config-completed.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { $NHSNotifyEventEnvelope } from '../event-envelope'; +import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; + +const $RoutingConfigCompletedEventV1Data = z + .intersection( + $RoutingConfigEventV1Data, + z.object({ + status: $RoutingConfigStatus.extract(['COMPLETED']), + }) + ) + .meta({ + id: 'RoutingConfigCompletedEventData', + }); + +export const $RoutingConfigCompletedEventV1 = $NHSNotifyEventEnvelope.extend({ + type: z.literal('uk.nhs.notify.template-management.RoutingConfigCompleted.v1'), + dataschema: z.literal( + 'https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json' + ), + dataschemaversion: z.string().startsWith('1.'), + plane: z.literal('control'), + data: $RoutingConfigCompletedEventV1Data, +}); + +export type RoutingConfigCompletedEventV1 = z.infer< + typeof $RoutingConfigCompletedEventV1 +>; diff --git a/packages/event-schemas/src/events/routing-config-deleted.ts b/packages/event-schemas/src/events/routing-config-deleted.ts new file mode 100644 index 000000000..91454ac7e --- /dev/null +++ b/packages/event-schemas/src/events/routing-config-deleted.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { $NHSNotifyEventEnvelope } from '../event-envelope'; +import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; + +const $RoutingConfigDeletedEventV1Data = z + .intersection( + $RoutingConfigEventV1Data, + z.object({ + status: $RoutingConfigStatus.extract(['DELETED']), + }) + ) + .meta({ + id: 'RoutingConfigDeletedEventData', + }); + +export const $RoutingConfigDeletedEventV1 = $NHSNotifyEventEnvelope.extend({ + type: z.literal('uk.nhs.notify.template-management.RoutingConfigDeleted.v1'), + dataschema: z.literal( + 'https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json' + ), + dataschemaversion: z.string().startsWith('1.'), + plane: z.literal('control'), + data: $RoutingConfigDeletedEventV1Data, +}); + +export type RoutingConfigDeletedEventV1 = z.infer< + typeof $RoutingConfigDeletedEventV1 +>; diff --git a/packages/event-schemas/src/events/routing-config-drafted.ts b/packages/event-schemas/src/events/routing-config-drafted.ts new file mode 100644 index 000000000..71c9a8606 --- /dev/null +++ b/packages/event-schemas/src/events/routing-config-drafted.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { $NHSNotifyEventEnvelope } from '../event-envelope'; +import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; + +const $RoutingConfigDraftedEventV1Data = z + .intersection( + $RoutingConfigEventV1Data, + z.object({ + status: $RoutingConfigStatus.extract(['DRAFT']), + }) + ) + .meta({ + id: 'RoutingConfigDraftedEventData', + }); + +export const $RoutingConfigDraftedEventV1 = $NHSNotifyEventEnvelope.extend({ + type: z.literal('uk.nhs.notify.template-management.RoutingConfigDrafted.v1'), + dataschema: z.literal( + 'https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json' + ), + dataschemaversion: z.string().startsWith('1.'), + plane: z.literal('control'), + data: $RoutingConfigDraftedEventV1Data, +}); + +export type RoutingConfigDraftedEventV1 = z.infer< + typeof $RoutingConfigDraftedEventV1 +>; diff --git a/packages/event-schemas/src/routing-config.ts b/packages/event-schemas/src/routing-config.ts new file mode 100644 index 000000000..c35f0a363 --- /dev/null +++ b/packages/event-schemas/src/routing-config.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; +import { languages } from './common'; + +export const $RoutingConfigEventChannel = z.enum([ + 'NHS_APP', + 'EMAIL', + 'SMS', + 'LETTER', +]); + +export const $RoutingConfigStatus = z.enum([ + 'DELETED', + 'DRAFT', + 'COMPLETED', +]); + +const accessibleFormats = ['x1']; + +export type RoutingConfigEventChannel = z.infer< + typeof $RoutingConfigEventChannel +>; + +export const $RoutingConfigEventChannelType = z.enum(['primary', 'secondary']); +export type RoutingConfigEventChannelType = z.infer< + typeof $RoutingConfigEventChannelType +>; + +const $RoutingConfigEventConditionalTemplate = z.object({ + language: z.enum(languages).optional().meta({ + description: 'Language override for the template', + }), + accessibleFormat: z.enum(accessibleFormats).optional().meta({ + description: 'Communication preference override for the template', + }), + supplierReferences: z.record(z.string(), z.string()).optional().meta({ + description: 'Supplier references that identify the template', + }), + templateId: z.string().regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/).meta({ + description: 'Unique identifier for the template', + }), +}) + .meta({ + id: 'ConditionalTemplate', + }); +export type RoutingConfigEventConditionalTemplate = z.infer< + typeof $RoutingConfigEventConditionalTemplate +>; + +const $CascadeItem = z.object({ + channel: $RoutingConfigEventChannel.meta({ + description: 'Communication type for this cascade item', + }), + channelType: $RoutingConfigEventChannelType.meta({ + description: 'Channel type for this cascade item', + }), + defaultTemplateId: z.string().regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/).optional().meta({ + description: 'Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied', + }), + supplierReferences: z.record(z.string(), z.string()).optional().meta({ + description: 'Supplier references that identify the template', + }), + conditionalTemplates: z + .array($RoutingConfigEventConditionalTemplate) + .optional(), + cascadeGroups: z.array(z.string()).meta({ + description: 'List of cascade groups that the cascade item will be included in', + }), +}) + .meta({ + id: 'CascadeItem', + }); +export type CascadeItem = z.infer; + +const $CascadeGroupOverride = z.object({ + name: z.string(), + accessibleFormat: z.array(z.string()).optional(), + language: z.array(z.string()).optional(), +}) + .meta({ + id: 'CascadeGroupOverride', + });; +export type CascadeGroupOverride = z.infer; + +export const $RoutingConfigEventV1Data = z.object({ + clientId: z.string().meta({ + description: 'The client that owns the routing config', + }), + campaignId: z.string().meta({ + description: 'The campaign that is associated with the routing config', + }), + id: z.string().meta({ + description: 'Unique identifier of the routing config', + }).regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/), + name: z.string().meta({ + description: 'User-provided name identifying the routing config', + }), + defaultCascadeGroup: z.string().meta({ + description: 'Default cascade group name', + }), + createdAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/).meta({ + description: 'Timestamp for when the routing config was created', + }), + cascade: z.array($CascadeItem).meta({ + description: 'Array defining the order of channels for the routing config and how they are configured', + }), + cascadeGroupOverrides: z.array($CascadeGroupOverride).meta({ + description: 'Config defining non-default cascade groups and the conditons under which they will be used', + }), + status: $RoutingConfigStatus.meta({ + description: 'Routing config status', + }), +}); diff --git a/packages/event-schemas/src/template.ts b/packages/event-schemas/src/template.ts index a436b1e50..35d1f2133 100644 --- a/packages/event-schemas/src/template.ts +++ b/packages/event-schemas/src/template.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { languages } from './common'; const templateStatuses = [ 'DELETED', @@ -13,38 +14,6 @@ const templateStatuses = [ 'WAITING_FOR_PROOF', ]; -const languages = [ - 'ar', - 'bg', - 'bn', - 'de', - 'el', - 'en', - 'es', - 'fa', - 'fr', - 'gu', - 'hi', - 'hu', - 'it', - 'ku', - 'lt', - 'lv', - 'ne', - 'pa', - 'pl', - 'pt', - 'ro', - 'ru', - 'sk', - 'so', - 'sq', - 'ta', - 'tr', - 'ur', - 'zh', -]; - const letterTypes = ['q4', 'x0', 'x1']; export const $TemplateStatus = z.enum(templateStatuses); From a00d969c0bd0145b18d1d2333bc1285d8f43ce20 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 2 Dec 2025 11:26:21 +0000 Subject: [PATCH 02/10] CCM-11976: Publish routing events --- .../components/sandbox/module_backend_api.tf | 2 - .../terraform/modules/backend-api/README.md | 1 - .../pipes_pipe_routing_config_table_events.tf | 2 +- .../modules/backend-api/variables.tf | 6 - .../__tests__/domain/event-builder.test.ts | 234 ++++++++++++++++-- .../src/domain/event-builder.ts | 77 +++++- .../src/domain/input-schemas.ts | 11 +- .../src/domain/output-schemas.ts | 6 + packages/event-schemas/package.json | 2 +- packages/event-schemas/src/routing-config.ts | 2 +- 10 files changed, 305 insertions(+), 38 deletions(-) diff --git a/infrastructure/terraform/components/sandbox/module_backend_api.tf b/infrastructure/terraform/components/sandbox/module_backend_api.tf index 162950bb9..8f283c14a 100644 --- a/infrastructure/terraform/components/sandbox/module_backend_api.tf +++ b/infrastructure/terraform/components/sandbox/module_backend_api.tf @@ -31,8 +31,6 @@ module "backend_api" { send_to_firehose = false - enable_routing_config_event_stream = true - email_domain = local.email_domain template_submitted_sender_email_address = local.sandbox_letter_supplier_mock_template_submitted_sender proof_requested_sender_email_address = local.sandbox_letter_supplier_mock_proof_requested_sender diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 0c868eb12..c16878af7 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -16,7 +16,6 @@ No requirements. | [csi](#input\_csi) | CSI from the parent component | `string` | n/a | yes | | [email\_domain](#input\_email\_domain) | Email domain | `string` | n/a | yes | | [enable\_backup](#input\_enable\_backup) | Enable Backups for the DynamoDB table? | `bool` | `true` | no | -| [enable\_routing\_config\_event\_stream](#input\_enable\_routing\_config\_event\_stream) | Enable DynamoDB streaming from routing config table to EventBridge | `bool` | `false` | no | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | | [function\_s3\_bucket](#input\_function\_s3\_bucket) | Name of S3 bucket to upload lambda artefacts to | `string` | n/a | yes | | [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | diff --git a/infrastructure/terraform/modules/backend-api/pipes_pipe_routing_config_table_events.tf b/infrastructure/terraform/modules/backend-api/pipes_pipe_routing_config_table_events.tf index cbd05d810..4fcb1320e 100644 --- a/infrastructure/terraform/modules/backend-api/pipes_pipe_routing_config_table_events.tf +++ b/infrastructure/terraform/modules/backend-api/pipes_pipe_routing_config_table_events.tf @@ -5,7 +5,7 @@ resource "aws_pipes_pipe" "routing_config_table_events" { role_arn = aws_iam_role.pipe_routing_config_table_events.arn source = aws_dynamodb_table.routing_configuration.stream_arn target = module.sqs_template_mgmt_events.sqs_queue_arn - desired_state = var.enable_routing_config_event_stream ? "RUNNING" : "STOPPED" + desired_state = "RUNNING" kms_key_identifier = var.kms_key_arn source_parameters { diff --git a/infrastructure/terraform/modules/backend-api/variables.tf b/infrastructure/terraform/modules/backend-api/variables.tf index 4871ffe48..68b441580 100644 --- a/infrastructure/terraform/modules/backend-api/variables.tf +++ b/infrastructure/terraform/modules/backend-api/variables.tf @@ -71,12 +71,6 @@ variable "enable_backup" { default = true } -variable "enable_routing_config_event_stream" { - type = bool - description = "Enable DynamoDB streaming from routing config table to EventBridge" - default = false -} - variable "kms_key_arn" { type = string description = "KMS Key ARN" diff --git a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts index 454a77b82..17eea9318 100644 --- a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts +++ b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts @@ -206,7 +206,11 @@ const publishableTemplateEventRecord = ( tableName: tables.templates, }); -const expectedEvent = (status: string, type: string, dataschema: string) => ({ +const expectedTemplateEvent = ( + status: string, + type: string, + dataschema: string +) => ({ id: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d', datacontenttype: 'application/json', time: '2022-01-01T09:00:00.000Z', @@ -241,6 +245,130 @@ const expectedEvent = (status: string, type: string, dataschema: string) => ({ }, }); +const publishableRoutingConfigEventRecord = (status: string) => ({ + dynamodb: { + SequenceNumber: '4', + NewImage: { + owner: { + S: 'owner', + }, + id: { + S: '92b676e9-470f-4d04-ab14-965ef145e15d', + }, + clientId: { + S: 'client-id', + }, + campaignId: { + S: 'campaign-id', + }, + createdAt: { + S: '2022-01-01T09:00:00.000Z', + }, + name: { + S: 'routing-config-name', + }, + defaultCascadeGroup: { + S: 'standard', + }, + cascade: { + L: [ + { + M: { + channel: { S: 'EMAIL' }, + channelType: { S: 'primary' }, + defaultTemplateId: { S: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd' }, + cascadeGroups: { L: [{ S: 'standard' }] }, + }, + }, + { + M: { + channel: { S: 'LETTER' }, + channelType: { S: 'primary' }, + defaultTemplateId: { S: 'd290f1ee-6c54-4b01-90e6-d701748f0851' }, + cascadeGroups: { L: [{ S: 'standard' }] }, + }, + }, + { + M: { + channel: { S: 'LETTER' }, + channelType: { S: 'primary' }, + defaultTemplateId: { S: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }, + cascadeGroups: { L: [{ S: 'translations' }] }, + }, + }, + ], + }, + cascadeGroupOverrides: { + L: [ + { + M: { + name: { S: 'translations' }, + language: { L: [{ S: 'fr' }] }, + }, + }, + ], + }, + status: { + S: status, + }, + }, + }, + eventID: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d', + tableName: tables.routing, +}); + +const expectedRoutingConfigEvent = ( + status: string, + type: string, + dataschema: string +) => ({ + id: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d', + datacontenttype: 'application/json', + time: '2022-01-01T09:00:00.000Z', + source: 'event-source', + type, + specversion: '1.0', + dataschema, + dataschemaversion: VERSION, + plane: 'control', + subject: '92b676e9-470f-4d04-ab14-965ef145e15d', + data: { + id: '92b676e9-470f-4d04-ab14-965ef145e15d', + clientId: 'client-id', + campaignId: 'campaign-id', + createdAt: '2022-01-01T09:00:00.000Z', + name: 'routing-config-name', + defaultCascadeGroup: 'standard', + cascade: [ + { + channel: 'EMAIL', + channelType: 'primary', + cascadeGroups: ['standard'], + defaultTemplateId: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd', + }, + { + channel: 'LETTER', + channelType: 'primary', + cascadeGroups: ['standard'], + defaultTemplateId: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }, + { + channel: 'LETTER', + channelType: 'primary', + cascadeGroups: ['translations'], + defaultTemplateId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + }, + ], + cascadeGroupOverrides: [ + { + name: 'translations', + language: ['fr'], + }, + ], + status, + }, +}); + test('errors on unrecognised event table source', () => { const invalidpublishableTemplateEventRecord = { ...publishableTemplateEventRecord('SUBMITTED'), @@ -290,7 +418,7 @@ describe('template events', () => { ); expect(event).toEqual( - expectedEvent( + expectedTemplateEvent( 'SUBMITTED', 'uk.nhs.notify.template-management.TemplateCompleted.v1', 'https://notify.nhs.uk/events/schemas/TemplateCompleted/v1.json' @@ -304,7 +432,7 @@ describe('template events', () => { ); expect(event).toEqual( - expectedEvent( + expectedTemplateEvent( 'PROOF_AVAILABLE', 'uk.nhs.notify.template-management.TemplateDrafted.v1', 'https://notify.nhs.uk/events/schemas/TemplateDrafted/v1.json' @@ -327,7 +455,7 @@ describe('template events', () => { const event = eventBuilder.buildEvent(noOldImage); expect(event).toEqual( - expectedEvent( + expectedTemplateEvent( 'SUBMITTED', 'uk.nhs.notify.template-management.TemplateCompleted.v1', 'https://notify.nhs.uk/events/schemas/TemplateCompleted/v1.json' @@ -341,7 +469,7 @@ describe('template events', () => { ); expect(event).toEqual( - expectedEvent( + expectedTemplateEvent( 'DELETED', 'uk.nhs.notify.template-management.TemplateDeleted.v1', 'https://notify.nhs.uk/events/schemas/TemplateDeleted/v1.json' @@ -361,7 +489,7 @@ describe('template events', () => { }); test('does not build template event on hard delete', () => { - const hardDeletepublishableTemplateEventRecord = { + const hardDeletePublishableTemplateEventRecord = { ...publishableTemplateEventRecord('SUBMITTED'), dynamodb: { SequenceNumber: '4', @@ -370,7 +498,7 @@ describe('template events', () => { }; const event = eventBuilder.buildEvent( - hardDeletepublishableTemplateEventRecord + hardDeletePublishableTemplateEventRecord ); expect(event).toEqual(undefined); @@ -378,16 +506,92 @@ describe('template events', () => { }); describe('routing config events', () => { - test('should return undefined when table source is routing config table', () => { - const event = eventBuilder.buildEvent({ + test('errors on output schema validation failure', () => { + const valid = publishableRoutingConfigEventRecord('DRAFT'); + + const invalidDomainEventRecord = { + ...valid, + dynamodb: { + ...valid.dynamodb, + NewImage: { + ...valid.dynamodb.NewImage, + cascade: { + L: [ + { + M: { + channel: { S: 'EMAIL' }, + channelType: { S: 'primary' }, + defaultTemplateId: { S: null }, + cascadeGroups: { L: [{ S: 'standard' }] }, + }, + }, + ], + }, + }, + }, + }; + + const event = eventBuilder.buildEvent( + invalidDomainEventRecord as unknown as PublishableEventRecord + ); + + expect(event).toEqual(undefined); + }); + + test('builds routing config completed event', () => { + const event = eventBuilder.buildEvent( + publishableRoutingConfigEventRecord('COMPLETED') + ); + + expect(event).toEqual( + expectedRoutingConfigEvent( + 'COMPLETED', + 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json' + ) + ); + }); + + test('builds routing config drafted event', () => { + const event = eventBuilder.buildEvent( + publishableRoutingConfigEventRecord('DRAFT') + ); + + expect(event).toEqual( + expectedRoutingConfigEvent( + 'DRAFT', + 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json' + ) + ); + }); + + test('builds routing config deleted event', () => { + const event = eventBuilder.buildEvent( + publishableRoutingConfigEventRecord('DELETED') + ); + + expect(event).toEqual( + expectedRoutingConfigEvent( + 'DELETED', + 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + 'https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json' + ) + ); + }); + + test('does not build routing config event on hard delete', () => { + const hardDeletePublishableRoutingConfigEventRecord = { + ...publishableRoutingConfigEventRecord('DRAFT'), dynamodb: { - SequenceNumber: '1', - NewImage: {}, - OldImage: {}, + SequenceNumber: '4', + NewImage: undefined, }, - eventID: 'cf1344e0-fd57-426a-860a-3efc9d2b1977', - tableName: tables.routing, - }); + }; + + const event = eventBuilder.buildEvent( + hardDeletePublishableRoutingConfigEventRecord + ); expect(event).toEqual(undefined); }); diff --git a/lambdas/event-publisher/src/domain/event-builder.ts b/lambdas/event-publisher/src/domain/event-builder.ts index c896361ae..d6d401333 100644 --- a/lambdas/event-publisher/src/domain/event-builder.ts +++ b/lambdas/event-publisher/src/domain/event-builder.ts @@ -4,7 +4,11 @@ import { VERSION, } from '@nhsdigital/nhs-notify-event-schemas-template-management'; import { Logger } from 'nhs-notify-web-template-management-utils/logger'; -import { $DynamoDBTemplate, PublishableEventRecord } from './input-schemas'; +import { + $DynamoDBRoutingConfig, + $DynamoDBTemplate, + PublishableEventRecord, +} from './input-schemas'; import { Event, $Event } from './output-schemas'; import { shouldPublish } from './should-publish'; @@ -32,13 +36,23 @@ export class EventBuilder { } } - private buildTemplateSavedEventMetadata( - id: string, - templateStatus: string, - subject: string - ) { - const type = this.classifyTemplateSavedEventType(templateStatus); + private classifyRoutingConfigSavedEventType(status: string) { + switch (status) { + case 'DELETED': { + return 'RoutingConfigDeleted'; + } + case 'COMPLETED': { + return 'RoutingConfigCompleted'; + } + + default: { + return 'RoutingConfigDrafted'; + } + } + } + + private buildEventMetadata(id: string, type: string, subject: string) { return { id, datacontenttype: 'application/json', @@ -89,9 +103,11 @@ export class EventBuilder { try { return $Event.parse({ - ...this.buildTemplateSavedEventMetadata( + ...this.buildEventMetadata( publishableEventRecord.eventID, - databaseTemplateNew.templateStatus, + this.classifyTemplateSavedEventType( + databaseTemplateNew.templateStatus + ), databaseTemplateNew.id ), data: dynamoRecordNew, @@ -108,6 +124,47 @@ export class EventBuilder { } } + private buildRoutingConfigDatabaseEvent( + publishableEventRecord: PublishableEventRecord + ): Event | undefined { + if (!publishableEventRecord.dynamodb.NewImage) { + // if this is a hard delete do not publish an event - we will publish events + // when the status is set to deleted + this.logger.debug({ + description: 'No new image found', + publishableEventRecord, + }); + + return undefined; + } + const dynamoRecord = unmarshall(publishableEventRecord.dynamodb.NewImage); + + const databaseRoutingConfig = $DynamoDBRoutingConfig.parse(dynamoRecord); + + const event = $Event.safeParse({ + ...this.buildEventMetadata( + publishableEventRecord.eventID, + this.classifyRoutingConfigSavedEventType(databaseRoutingConfig.status), + databaseRoutingConfig.id + ), + data: dynamoRecord, + }); + + // Do not error if the event is not valid because routing config database entries may be + // in a state where we do not yet want to publish them (such as having null template values) + if (!event.success) { + this.logger.info({ + description: 'Routing config event is not in a valid state', + publishableEventRecord, + errors: event.error, + }); + + return undefined; + } + + return event.data; + } + buildEvent( publishableEventRecord: PublishableEventRecord ): Event | undefined { @@ -116,7 +173,7 @@ export class EventBuilder { return this.buildTemplateDatabaseEvent(publishableEventRecord); } case this.routingConfigTableName: { - return undefined; + return this.buildRoutingConfigDatabaseEvent(publishableEventRecord); } default: { this.logger.error({ diff --git a/lambdas/event-publisher/src/domain/input-schemas.ts b/lambdas/event-publisher/src/domain/input-schemas.ts index 49c1d201b..507a37e30 100644 --- a/lambdas/event-publisher/src/domain/input-schemas.ts +++ b/lambdas/event-publisher/src/domain/input-schemas.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; import type { AttributeValue } from '@aws-sdk/client-dynamodb'; import { + RoutingConfig, schemaFor, TEMPLATE_STATUS_LIST, TEMPLATE_TYPE_LIST, + ROUTING_CONFIG_STATUS_LIST, } from 'nhs-notify-backend-client'; import type { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; @@ -41,9 +43,16 @@ export const $DynamoDBTemplate = schemaFor>()( proofingEnabled: z.boolean().optional(), }) ); - export type DynamoDBTemplate = z.infer; +export const $DynamoDBRoutingConfig = schemaFor>()( + z.object({ + id: z.string(), + status: z.enum(ROUTING_CONFIG_STATUS_LIST), + }) +); +export type DynamoDBRoutingConfig = z.infer; + // the lambda doesn't necessarily have to only accept inputs from a dynamodb stream via an // eventbridge pipe, but that's all it is doing at the moment export const $PublishableEventRecord = $DynamoDBStreamRecord; diff --git a/lambdas/event-publisher/src/domain/output-schemas.ts b/lambdas/event-publisher/src/domain/output-schemas.ts index cdb8dba00..be920e719 100644 --- a/lambdas/event-publisher/src/domain/output-schemas.ts +++ b/lambdas/event-publisher/src/domain/output-schemas.ts @@ -1,4 +1,7 @@ import { + $RoutingConfigCompletedEventV1, + $RoutingConfigDeletedEventV1, + $RoutingConfigDraftedEventV1, $TemplateCompletedEventV1, $TemplateDeletedEventV1, $TemplateDraftedEventV1, @@ -11,5 +14,8 @@ export const $Event = z.discriminatedUnion('type', [ $TemplateCompletedEventV1, $TemplateDraftedEventV1, $TemplateDeletedEventV1, + $RoutingConfigCompletedEventV1, + $RoutingConfigDraftedEventV1, + $RoutingConfigDeletedEventV1, ]); export type Event = z.infer; diff --git a/packages/event-schemas/package.json b/packages/event-schemas/package.json index 26d58f628..4c17a7a7a 100644 --- a/packages/event-schemas/package.json +++ b/packages/event-schemas/package.json @@ -56,5 +56,5 @@ }, "type": "commonjs", "types": "./dist/index.d.ts", - "version": "1.1.2" + "version": "1.2.0" } diff --git a/packages/event-schemas/src/routing-config.ts b/packages/event-schemas/src/routing-config.ts index c35f0a363..0fa1cc787 100644 --- a/packages/event-schemas/src/routing-config.ts +++ b/packages/event-schemas/src/routing-config.ts @@ -78,7 +78,7 @@ const $CascadeGroupOverride = z.object({ }) .meta({ id: 'CascadeGroupOverride', - });; + }); export type CascadeGroupOverride = z.infer; export const $RoutingConfigEventV1Data = z.object({ From f2ee9e8bd32e1049dfe2d6b2669cc1e7a3f41a67 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 2 Dec 2025 11:37:11 +0000 Subject: [PATCH 03/10] CCM-11976: Fix linting --- .../src/events/routing-config-completed.ts | 9 +- .../src/events/routing-config-deleted.ts | 5 +- .../src/events/routing-config-drafted.ts | 5 +- packages/event-schemas/src/routing-config.ts | 97 +++++++++++-------- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/packages/event-schemas/src/events/routing-config-completed.ts b/packages/event-schemas/src/events/routing-config-completed.ts index dde2897d8..819b5711e 100644 --- a/packages/event-schemas/src/events/routing-config-completed.ts +++ b/packages/event-schemas/src/events/routing-config-completed.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { $NHSNotifyEventEnvelope } from '../event-envelope'; -import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; +import { + $RoutingConfigEventV1Data, + $RoutingConfigStatus, +} from '../routing-config'; const $RoutingConfigCompletedEventV1Data = z .intersection( @@ -14,7 +17,9 @@ const $RoutingConfigCompletedEventV1Data = z }); export const $RoutingConfigCompletedEventV1 = $NHSNotifyEventEnvelope.extend({ - type: z.literal('uk.nhs.notify.template-management.RoutingConfigCompleted.v1'), + type: z.literal( + 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1' + ), dataschema: z.literal( 'https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json' ), diff --git a/packages/event-schemas/src/events/routing-config-deleted.ts b/packages/event-schemas/src/events/routing-config-deleted.ts index 91454ac7e..ff683e898 100644 --- a/packages/event-schemas/src/events/routing-config-deleted.ts +++ b/packages/event-schemas/src/events/routing-config-deleted.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { $NHSNotifyEventEnvelope } from '../event-envelope'; -import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; +import { + $RoutingConfigEventV1Data, + $RoutingConfigStatus, +} from '../routing-config'; const $RoutingConfigDeletedEventV1Data = z .intersection( diff --git a/packages/event-schemas/src/events/routing-config-drafted.ts b/packages/event-schemas/src/events/routing-config-drafted.ts index 71c9a8606..ff73b423f 100644 --- a/packages/event-schemas/src/events/routing-config-drafted.ts +++ b/packages/event-schemas/src/events/routing-config-drafted.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { $NHSNotifyEventEnvelope } from '../event-envelope'; -import { $RoutingConfigEventV1Data, $RoutingConfigStatus } from '../routing-config'; +import { + $RoutingConfigEventV1Data, + $RoutingConfigStatus, +} from '../routing-config'; const $RoutingConfigDraftedEventV1Data = z .intersection( diff --git a/packages/event-schemas/src/routing-config.ts b/packages/event-schemas/src/routing-config.ts index 0fa1cc787..dcb7a64ac 100644 --- a/packages/event-schemas/src/routing-config.ts +++ b/packages/event-schemas/src/routing-config.ts @@ -8,11 +8,7 @@ export const $RoutingConfigEventChannel = z.enum([ 'LETTER', ]); -export const $RoutingConfigStatus = z.enum([ - 'DELETED', - 'DRAFT', - 'COMPLETED', -]); +export const $RoutingConfigStatus = z.enum(['DELETED', 'DRAFT', 'COMPLETED']); const accessibleFormats = ['x1']; @@ -25,20 +21,25 @@ export type RoutingConfigEventChannelType = z.infer< typeof $RoutingConfigEventChannelType >; -const $RoutingConfigEventConditionalTemplate = z.object({ - language: z.enum(languages).optional().meta({ +const $RoutingConfigEventConditionalTemplate = z + .object({ + language: z.enum(languages).optional().meta({ description: 'Language override for the template', }), - accessibleFormat: z.enum(accessibleFormats).optional().meta({ + accessibleFormat: z.enum(accessibleFormats).optional().meta({ description: 'Communication preference override for the template', }), - supplierReferences: z.record(z.string(), z.string()).optional().meta({ + supplierReferences: z.record(z.string(), z.string()).optional().meta({ description: 'Supplier references that identify the template', }), - templateId: z.string().regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/).meta({ - description: 'Unique identifier for the template', - }), -}) + templateId: z + .string() + // eslint-disable-next-line security/detect-unsafe-regex + .regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/) + .meta({ + description: 'Unique identifier for the template', + }), + }) .meta({ id: 'ConditionalTemplate', }); @@ -46,36 +47,45 @@ export type RoutingConfigEventConditionalTemplate = z.infer< typeof $RoutingConfigEventConditionalTemplate >; -const $CascadeItem = z.object({ - channel: $RoutingConfigEventChannel.meta({ +const $CascadeItem = z + .object({ + channel: $RoutingConfigEventChannel.meta({ description: 'Communication type for this cascade item', }), - channelType: $RoutingConfigEventChannelType.meta({ + channelType: $RoutingConfigEventChannelType.meta({ description: 'Channel type for this cascade item', }), - defaultTemplateId: z.string().regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/).optional().meta({ - description: 'Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied', - }), - supplierReferences: z.record(z.string(), z.string()).optional().meta({ + defaultTemplateId: z + .string() + // eslint-disable-next-line security/detect-unsafe-regex + .regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/) + .optional() + .meta({ + description: + 'Unique identifier for the template to use if no conditions for conditionalTemplates are satisfied', + }), + supplierReferences: z.record(z.string(), z.string()).optional().meta({ description: 'Supplier references that identify the template', }), - conditionalTemplates: z - .array($RoutingConfigEventConditionalTemplate) - .optional(), - cascadeGroups: z.array(z.string()).meta({ - description: 'List of cascade groups that the cascade item will be included in', + conditionalTemplates: z + .array($RoutingConfigEventConditionalTemplate) + .optional(), + cascadeGroups: z.array(z.string()).meta({ + description: + 'List of cascade groups that the cascade item will be included in', }), -}) + }) .meta({ id: 'CascadeItem', }); export type CascadeItem = z.infer; -const $CascadeGroupOverride = z.object({ - name: z.string(), - accessibleFormat: z.array(z.string()).optional(), - language: z.array(z.string()).optional(), -}) +const $CascadeGroupOverride = z + .object({ + name: z.string(), + accessibleFormat: z.array(z.string()).optional(), + language: z.array(z.string()).optional(), + }) .meta({ id: 'CascadeGroupOverride', }); @@ -88,23 +98,32 @@ export const $RoutingConfigEventV1Data = z.object({ campaignId: z.string().meta({ description: 'The campaign that is associated with the routing config', }), - id: z.string().meta({ - description: 'Unique identifier of the routing config', - }).regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/), + id: z + .string() + .meta({ + description: 'Unique identifier of the routing config', + }) + // eslint-disable-next-line security/detect-unsafe-regex + .regex(/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/), name: z.string().meta({ description: 'User-provided name identifying the routing config', }), defaultCascadeGroup: z.string().meta({ description: 'Default cascade group name', }), - createdAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/).meta({ - description: 'Timestamp for when the routing config was created', - }), + createdAt: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + .meta({ + description: 'Timestamp for when the routing config was created', + }), cascade: z.array($CascadeItem).meta({ - description: 'Array defining the order of channels for the routing config and how they are configured', + description: + 'Array defining the order of channels for the routing config and how they are configured', }), cascadeGroupOverrides: z.array($CascadeGroupOverride).meta({ - description: 'Config defining non-default cascade groups and the conditons under which they will be used', + description: + 'Config defining non-default cascade groups and the conditons under which they will be used', }), status: $RoutingConfigStatus.meta({ description: 'Routing config status', From 344aee7b0ecdcc68e7bc0bd2de872a1e1fb53214 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 2 Dec 2025 12:14:07 +0000 Subject: [PATCH 04/10] CCM-11976: Change channel name formatting --- packages/event-schemas/src/routing-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/event-schemas/src/routing-config.ts b/packages/event-schemas/src/routing-config.ts index dcb7a64ac..a71c669bb 100644 --- a/packages/event-schemas/src/routing-config.ts +++ b/packages/event-schemas/src/routing-config.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { languages } from './common'; export const $RoutingConfigEventChannel = z.enum([ - 'NHS_APP', + 'NHSAPP', 'EMAIL', 'SMS', 'LETTER', From 24e3863a33be67367bc4d344ffcbb6b7c6b8fa51 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 2 Dec 2025 12:27:02 +0000 Subject: [PATCH 05/10] CCM-11976: Regenerate json schemas --- packages/event-schemas/schemas/RoutingConfigCompleted/v1.json | 2 +- packages/event-schemas/schemas/RoutingConfigDeleted/v1.json | 2 +- packages/event-schemas/schemas/RoutingConfigDrafted/v1.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json b/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json index 2baafd61b..47ecf8ddc 100644 --- a/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json +++ b/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json @@ -155,7 +155,7 @@ "description": "Communication type for this cascade item", "type": "string", "enum": [ - "NHS_APP", + "NHSAPP", "EMAIL", "SMS", "LETTER" diff --git a/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json b/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json index b9314bfd8..27bdd0301 100644 --- a/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json +++ b/packages/event-schemas/schemas/RoutingConfigDeleted/v1.json @@ -155,7 +155,7 @@ "description": "Communication type for this cascade item", "type": "string", "enum": [ - "NHS_APP", + "NHSAPP", "EMAIL", "SMS", "LETTER" diff --git a/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json b/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json index b214ce743..ed15763c9 100644 --- a/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json +++ b/packages/event-schemas/schemas/RoutingConfigDrafted/v1.json @@ -155,7 +155,7 @@ "description": "Communication type for this cascade item", "type": "string", "enum": [ - "NHS_APP", + "NHSAPP", "EMAIL", "SMS", "LETTER" From 0a20ae94d492a4a1521e82bbe9fdbe682b43c725 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Wed, 3 Dec 2025 12:09:54 +0000 Subject: [PATCH 06/10] CM-11976: Add event tests --- .../v1/routing-config.json | 100 ++++++++++++++ .../v1/routing-config.json | 100 ++++++++++++++ .../v1/routing-config.json | 100 ++++++++++++++ .../helpers/events/event-cache-helper.ts | 10 +- .../routing-config.event.spec.ts | 127 +++++++++++++++--- 5 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 packages/event-schemas/examples/RoutingConfigCompleted/v1/routing-config.json create mode 100644 packages/event-schemas/examples/RoutingConfigDeleted/v1/routing-config.json create mode 100644 packages/event-schemas/examples/RoutingConfigDrafted/v1/routing-config.json diff --git a/packages/event-schemas/examples/RoutingConfigCompleted/v1/routing-config.json b/packages/event-schemas/examples/RoutingConfigCompleted/v1/routing-config.json new file mode 100644 index 000000000..08495d598 --- /dev/null +++ b/packages/event-schemas/examples/RoutingConfigCompleted/v1/routing-config.json @@ -0,0 +1,100 @@ +{ + "data": { + "campaignId": "campaign-id", + "cascade": [ + { + "cascadeGroups": [ + "standard" + ], + "channel": "NHS_APP", + "channelType": "primary", + "defaultTemplateId": "nhs-app-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "EMAIL", + "channelType": "primary", + "defaultTemplateId": "email-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "SMS", + "channelType": "primary", + "defaultTemplateId": "sms-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "LETTER", + "channelType": "primary", + "defaultTemplateId": "letter-template-id" + }, + { + "cascadeGroups": [ + "other-language" + ], + "channel": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "language": "fr", + "templateId": "fr-template-id" + }, + { + "language": "ar", + "templateId": "ar-template-id" + } + ] + }, + { + "cascadeGroups": [ + "other-format" + ], + "channe": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "accessibleFormat": "x1", + "templateId": "x1-template-id" + } + ] + } + ], + "cascadeGroupOverrides": [ + { + "language": [ + "fr", + "ar" + ], + "name": "other-language" + }, + { + "accessibleFormat": [ + "x1" + ], + "name": "other-format" + } + ], + "clientId": "client-id", + "createdAt": "2025-07-29T08:45:00.000Z", + "defaultCascadeGroup": "standard", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "name": "routing-config-name", + "status": "COMPLETED" + }, + "datacontenttype": "application/json", + "dataschema": "https://notify.nhs.uk/events/schemas/RoutingConfigCompleted/v1.json", + "dataschemaversion": "1.0.0", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "plane": "control", + "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main", + "specversion": "1.0", + "subject": "871b0f4e-0b32-474e-a49a-23fc554bc5a6", + "time": "2025-07-29T08:45:00.000Z", + "type": "uk.nhs.notify.template-management.RoutingConfigCompleted.v1" +} diff --git a/packages/event-schemas/examples/RoutingConfigDeleted/v1/routing-config.json b/packages/event-schemas/examples/RoutingConfigDeleted/v1/routing-config.json new file mode 100644 index 000000000..e5ff70652 --- /dev/null +++ b/packages/event-schemas/examples/RoutingConfigDeleted/v1/routing-config.json @@ -0,0 +1,100 @@ +{ + "data": { + "campaignId": "campaign-id", + "cascade": [ + { + "cascadeGroups": [ + "standard" + ], + "channel": "NHS_APP", + "channelType": "primary", + "defaultTemplateId": "nhs-app-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "EMAIL", + "channelType": "primary", + "defaultTemplateId": "email-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "SMS", + "channelType": "primary", + "defaultTemplateId": "sms-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "LETTER", + "channelType": "primary", + "defaultTemplateId": "letter-template-id" + }, + { + "cascadeGroups": [ + "other-language" + ], + "channel": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "language": "fr", + "templateId": "fr-template-id" + }, + { + "language": "ar", + "templateId": "ar-template-id" + } + ] + }, + { + "cascadeGroups": [ + "other-format" + ], + "channe": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "accessibleFormat": "x1", + "templateId": "x1-template-id" + } + ] + } + ], + "cascadeGroupOverrides": [ + { + "language": [ + "fr", + "ar" + ], + "name": "other-language" + }, + { + "accessibleFormat": [ + "x1" + ], + "name": "other-format" + } + ], + "clientId": "client-id", + "createdAt": "2025-07-29T08:45:00.000Z", + "defaultCascadeGroup": "standard", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "name": "routing-config-name", + "status": "DELETED" + }, + "datacontenttype": "application/json", + "dataschema": "https://notify.nhs.uk/events/schemas/RoutingConfigDeleted/v1.json", + "dataschemaversion": "1.0.0", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "plane": "control", + "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main", + "specversion": "1.0", + "subject": "871b0f4e-0b32-474e-a49a-23fc554bc5a6", + "time": "2025-07-29T08:45:00.000Z", + "type": "uk.nhs.notify.template-management.RoutingConfigDeleted.v1" +} diff --git a/packages/event-schemas/examples/RoutingConfigDrafted/v1/routing-config.json b/packages/event-schemas/examples/RoutingConfigDrafted/v1/routing-config.json new file mode 100644 index 000000000..f8ae7b2ad --- /dev/null +++ b/packages/event-schemas/examples/RoutingConfigDrafted/v1/routing-config.json @@ -0,0 +1,100 @@ +{ + "data": { + "campaignId": "campaign-id", + "cascade": [ + { + "cascadeGroups": [ + "standard" + ], + "channel": "NHS_APP", + "channelType": "primary", + "defaultTemplateId": "nhs-app-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "EMAIL", + "channelType": "primary", + "defaultTemplateId": "email-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "SMS", + "channelType": "primary", + "defaultTemplateId": "sms-template-id" + }, + { + "cascadeGroups": [ + "standard" + ], + "channel": "LETTER", + "channelType": "primary", + "defaultTemplateId": "letter-template-id" + }, + { + "cascadeGroups": [ + "other-language" + ], + "channel": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "language": "fr", + "templateId": "fr-template-id" + }, + { + "language": "ar", + "templateId": "ar-template-id" + } + ] + }, + { + "cascadeGroups": [ + "other-format" + ], + "channe": "LETTER", + "channelType": "primary", + "conditionalTemplates": [ + { + "accessibleFormat": "x1", + "templateId": "x1-template-id" + } + ] + } + ], + "cascadeGroupOverrides": [ + { + "language": [ + "fr", + "ar" + ], + "name": "other-language" + }, + { + "accessibleFormat": [ + "x1" + ], + "name": "other-format" + } + ], + "clientId": "client-id", + "createdAt": "2025-07-29T08:45:00.000Z", + "defaultCascadeGroup": "standard", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "name": "routing-config-name", + "status": "DRAFT" + }, + "datacontenttype": "application/json", + "dataschema": "https://notify.nhs.uk/events/schemas/RoutingConfigDrafted/v1.json", + "dataschemaversion": "1.0.0", + "id": "12f1f09c-a555-4d9b-8405-0b33490bc929", + "plane": "control", + "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main", + "specversion": "1.0", + "subject": "871b0f4e-0b32-474e-a49a-23fc554bc5a6", + "time": "2025-07-29T08:45:00.000Z", + "type": "uk.nhs.notify.template-management.RoutingConfigDrafted.v1" +} diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts index 66c9dd0e3..74e73193d 100644 --- a/tests/test-team/helpers/events/event-cache-helper.ts +++ b/tests/test-team/helpers/events/event-cache-helper.ts @@ -26,9 +26,9 @@ export class EventCacheHelper { async findEvents( from: Date, - templateIds: string[] + ids: string[] ): Promise { - if (templateIds.length === 0) { + if (ids.length === 0) { return []; } @@ -41,7 +41,7 @@ export class EventCacheHelper { const filteredFiles = S3Helper.filterAndSort(files.flat(), from); const eventPromises = filteredFiles.map((file) => - this.queryFileForEvents(file.Key!, templateIds) + this.queryFileForEvents(file.Key!, ids) ); const results = await Promise.all(eventPromises); @@ -51,9 +51,9 @@ export class EventCacheHelper { private async queryFileForEvents( fileName: string, - templateIds: string[] + ids: string[] ): Promise { - const formattedIds = templateIds.map((r) => `'${r}'`); + const formattedIds = ids.map((r) => `'${r}'`); const response = await S3Helper.queryJSONLFile( this.bucketName, diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 7c4bf0186..a639cff44 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -5,8 +5,6 @@ import { testUsers, } from '../helpers/auth/cognito-auth-helper'; import { EventCacheHelper } from '../helpers/events/event-cache-helper'; -import { randomUUID } from 'node:crypto'; -import { setTimeout } from 'node:timers/promises'; import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; @@ -25,34 +23,133 @@ test.describe('Event publishing - Routing Config', () => { await storageHelper.deleteSeeded(); }); - test('Expect no events', async ({ request }) => { - const id = randomUUID(); + test('Expect a draft event and a deleted event', async ({ request }) => { + const payload = RoutingConfigFactory.create(user).apiPayload; - const messagePlan = RoutingConfigFactory.create(user, { id }).dbEntry; + const start = new Date(); + + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: payload, + } + ); + + expect(createResponse.status()).toBe(201); + + const { + data: { id, lockNumber }, + } = await createResponse.json(); + + storageHelper.addAdHocKey({ + id, + clientId: user.clientId, + }); + + const deleteResponse = await request.delete( + `${process.env.API_BASE_URL}/v1/routing-configuration/${id}`, + { + headers: { + Authorization: await user.getAccessToken(), + 'X-Lock-Number': String(lockNumber), + }, + } + ); + + expect(deleteResponse.status()).toBe(204); + + await expect(async () => { + const events = await eventCacheHelper.findEvents(start, [id]); + + expect(events).toHaveLength(2); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + templateStatus: 'DRAFT', + }), + }) + ); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + data: expect.objectContaining({ + id, + templateStatus: 'DELETED', + }), + }) + ); + }).toPass({ timeout: 60_000 }); + }); + + test('Expect a draft event and a completed event', async ({ request }) => { + const payload = RoutingConfigFactory.create(user).apiPayload; const start = new Date(); - await storageHelper.seed([messagePlan]); + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: payload, + } + ); - const submittedResponse = await request.patch( + expect(createResponse.status()).toBe(201); + + const { + data: { id, lockNumber }, + } = await createResponse.json(); + + storageHelper.addAdHocKey({ + id, + clientId: user.clientId, + }); + + const submitResponse = await request.patch( `${process.env.API_BASE_URL}/v1/routing-configuration/${id}/submit`, { headers: { Authorization: await user.getAccessToken(), - 'X-Lock-Number': String(messagePlan.lockNumber), + 'X-Lock-Number': String(lockNumber), }, } ); - expect(submittedResponse.status()).toBe(200); + expect(submitResponse.status()).toBe(200); + + await expect(async () => { + const events = await eventCacheHelper.findEvents(start, [id]); - // 5s is longest observed delivery delay - await setTimeout(5000); + expect(events).toHaveLength(2); - // This would throw if a routing config event was present, - // EventCacheHelper doesn't yet know how to handle routing config events - const events = await eventCacheHelper.findEvents(start, [id]); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + templateStatus: 'DRAFT', + }), + }) + ); - expect(events).toHaveLength(0); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', + data: expect.objectContaining({ + id, + templateStatus: 'COMPLETED', + }), + }) + ); + }).toPass({ timeout: 60_000 }); }); }); From 0640bcee64d5bcb096c053645dda61a2440fc4a5 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Wed, 3 Dec 2025 12:52:48 +0000 Subject: [PATCH 07/10] CM-11976: Fix tests --- .../routing-config.event.spec.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index a639cff44..4ef32b35f 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -24,7 +24,16 @@ test.describe('Event publishing - Routing Config', () => { }); test('Expect a draft event and a deleted event', async ({ request }) => { - const payload = RoutingConfigFactory.create(user).apiPayload; + const payload = RoutingConfigFactory.create(user, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: 'template-id', + }, + ], + }).apiPayload; const start = new Date(); @@ -89,7 +98,16 @@ test.describe('Event publishing - Routing Config', () => { }); test('Expect a draft event and a completed event', async ({ request }) => { - const payload = RoutingConfigFactory.create(user).apiPayload; + const payload = RoutingConfigFactory.create(user, { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: 'template-id', + }, + ], + }).apiPayload; const start = new Date(); From c10364995bf48407b6e52650f6e27a91032c530f Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Wed, 3 Dec 2025 13:15:17 +0000 Subject: [PATCH 08/10] CM-11976: Fix tests --- .../routing-config.event.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 4ef32b35f..a94f29e6d 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -30,7 +30,7 @@ test.describe('Event publishing - Routing Config', () => { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: 'template-id', + defaultTemplateId: 'b1854a33-fc1b-4e7d-99d0-6f7b92b8c530', }, ], }).apiPayload; @@ -73,8 +73,6 @@ test.describe('Event publishing - Routing Config', () => { await expect(async () => { const events = await eventCacheHelper.findEvents(start, [id]); - expect(events).toHaveLength(2); - expect(events).toContainEqual( expect.objectContaining({ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', @@ -94,6 +92,8 @@ test.describe('Event publishing - Routing Config', () => { }), }) ); + + expect(events).toHaveLength(2); }).toPass({ timeout: 60_000 }); }); @@ -104,7 +104,7 @@ test.describe('Event publishing - Routing Config', () => { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: 'template-id', + defaultTemplateId: 'b1854a33-fc1b-4e7d-99d0-6f7b92b8c530', }, ], }).apiPayload; @@ -147,8 +147,6 @@ test.describe('Event publishing - Routing Config', () => { await expect(async () => { const events = await eventCacheHelper.findEvents(start, [id]); - expect(events).toHaveLength(2); - expect(events).toContainEqual( expect.objectContaining({ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', @@ -168,6 +166,8 @@ test.describe('Event publishing - Routing Config', () => { }), }) ); + + expect(events).toHaveLength(2); }).toPass({ timeout: 60_000 }); }); }); From 5278f3f82ec73cf30959840ed6722b5d6d8a427b Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Wed, 3 Dec 2025 13:35:07 +0000 Subject: [PATCH 09/10] CM-11976: Fix tests --- .../helpers/events/event-cache-helper.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts index 74e73193d..e517a53f6 100644 --- a/tests/test-team/helpers/events/event-cache-helper.ts +++ b/tests/test-team/helpers/events/event-cache-helper.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { SelectObjectContentEventStream } from '@aws-sdk/client-s3'; import { + $RoutingConfigCompletedEventV1, + $RoutingConfigDeletedEventV1, + $RoutingConfigDraftedEventV1, $TemplateCompletedEventV1, $TemplateDeletedEventV1, $TemplateDraftedEventV1, @@ -13,21 +16,21 @@ import { } from 'date-fns'; import { S3Helper } from '../s3-helper'; -const $NHSNotifyTemplateEvent = z.discriminatedUnion('type', [ +const $NHSNotifyEvent = z.discriminatedUnion('type', [ $TemplateCompletedEventV1, $TemplateDraftedEventV1, $TemplateDeletedEventV1, + $RoutingConfigCompletedEventV1, + $RoutingConfigDraftedEventV1, + $RoutingConfigDeletedEventV1, ]); -type NHSNotifyTemplateEvent = z.infer; +type NHSNotifyEvent = z.infer; export class EventCacheHelper { private readonly bucketName = process.env.EVENT_CACHE_BUCKET_NAME; - async findEvents( - from: Date, - ids: string[] - ): Promise { + async findEvents(from: Date, ids: string[]): Promise { if (ids.length === 0) { return []; } @@ -52,7 +55,7 @@ export class EventCacheHelper { private async queryFileForEvents( fileName: string, ids: string[] - ): Promise { + ): Promise { const formattedIds = ids.map((r) => `'${r}'`); const response = await S3Helper.queryJSONLFile( @@ -71,8 +74,8 @@ export class EventCacheHelper { private async parse( fileName: string, payload: AsyncIterable - ): Promise { - const events: NHSNotifyTemplateEvent[] = []; + ): Promise { + const events: NHSNotifyEvent[] = []; for await (const event of payload) { if (!event.Records?.Payload) continue; @@ -83,7 +86,7 @@ export class EventCacheHelper { .split('\n') .filter((line) => line.trim()) .map((line) => { - const { data, success, error } = $NHSNotifyTemplateEvent.safeParse( + const { data, success, error } = $NHSNotifyEvent.safeParse( JSON.parse(line) ); From 2fdaf8b6364dcfb1fc8114760b7fc3cf0eb3c3f9 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Wed, 3 Dec 2025 13:55:32 +0000 Subject: [PATCH 10/10] CM-11976: Fix tests --- .../routing-config.event.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index a94f29e6d..05de01714 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -78,7 +78,7 @@ test.describe('Event publishing - Routing Config', () => { type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', data: expect.objectContaining({ id, - templateStatus: 'DRAFT', + status: 'DRAFT', }), }) ); @@ -88,7 +88,7 @@ test.describe('Event publishing - Routing Config', () => { type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', data: expect.objectContaining({ id, - templateStatus: 'DELETED', + status: 'DELETED', }), }) ); @@ -152,7 +152,7 @@ test.describe('Event publishing - Routing Config', () => { type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', data: expect.objectContaining({ id, - templateStatus: 'DRAFT', + status: 'DRAFT', }), }) ); @@ -162,7 +162,7 @@ test.describe('Event publishing - Routing Config', () => { type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', data: expect.objectContaining({ id, - templateStatus: 'COMPLETED', + status: 'COMPLETED', }), }) );