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/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/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/schemas/RoutingConfigCompleted/v1.json b/packages/event-schemas/schemas/RoutingConfigCompleted/v1.json
new file mode 100644
index 000000000..47ecf8ddc
--- /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": [
+ "NHSAPP",
+ "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..27bdd0301
--- /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": [
+ "NHSAPP",
+ "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..ed15763c9
--- /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": [
+ "NHSAPP",
+ "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..819b5711e
--- /dev/null
+++ b/packages/event-schemas/src/events/routing-config-completed.ts
@@ -0,0 +1,33 @@
+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..ff683e898
--- /dev/null
+++ b/packages/event-schemas/src/events/routing-config-deleted.ts
@@ -0,0 +1,31 @@
+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..ff73b423f
--- /dev/null
+++ b/packages/event-schemas/src/events/routing-config-drafted.ts
@@ -0,0 +1,31 @@
+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..a71c669bb
--- /dev/null
+++ b/packages/event-schemas/src/routing-config.ts
@@ -0,0 +1,131 @@
+import { z } from 'zod';
+import { languages } from './common';
+
+export const $RoutingConfigEventChannel = z.enum([
+ 'NHSAPP',
+ '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()
+ // 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',
+ });
+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()
+ // 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',
+ }),
+ })
+ .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',
+ })
+ // 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',
+ }),
+ 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);
diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts
index 66c9dd0e3..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,22 +16,22 @@ 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,
- templateIds: string[]
- ): Promise {
- if (templateIds.length === 0) {
+ async findEvents(from: Date, ids: string[]): Promise {
+ if (ids.length === 0) {
return [];
}
@@ -41,7 +44,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 +54,9 @@ export class EventCacheHelper {
private async queryFileForEvents(
fileName: string,
- templateIds: string[]
- ): Promise {
- const formattedIds = templateIds.map((r) => `'${r}'`);
+ ids: string[]
+ ): Promise {
+ const formattedIds = ids.map((r) => `'${r}'`);
const response = await S3Helper.queryJSONLFile(
this.bucketName,
@@ -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)
);
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..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
@@ -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,151 @@ 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, {
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: 'b1854a33-fc1b-4e7d-99d0-6f7b92b8c530',
+ },
+ ],
+ }).apiPayload;
+
+ const start = new Date();
+
+ const createResponse = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`,
+ {
+ headers: {
+ Authorization: await user.getAccessToken(),
+ },
+ data: payload,
+ }
+ );
- const messagePlan = RoutingConfigFactory.create(user, { id }).dbEntry;
+ 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).toContainEqual(
+ expect.objectContaining({
+ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1',
+ data: expect.objectContaining({
+ id,
+ status: 'DRAFT',
+ }),
+ })
+ );
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1',
+ data: expect.objectContaining({
+ id,
+ status: 'DELETED',
+ }),
+ })
+ );
+
+ expect(events).toHaveLength(2);
+ }).toPass({ timeout: 60_000 });
+ });
+
+ test('Expect a draft event and a completed event', async ({ request }) => {
+ const payload = RoutingConfigFactory.create(user, {
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: 'b1854a33-fc1b-4e7d-99d0-6f7b92b8c530',
+ },
+ ],
+ }).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,
+ }
+ );
+
+ expect(createResponse.status()).toBe(201);
+
+ const {
+ data: { id, lockNumber },
+ } = await createResponse.json();
- const submittedResponse = await request.patch(
+ 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).toContainEqual(
+ expect.objectContaining({
+ type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1',
+ data: expect.objectContaining({
+ id,
+ status: 'DRAFT',
+ }),
+ })
+ );
- // 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.RoutingConfigCompleted.v1',
+ data: expect.objectContaining({
+ id,
+ status: 'COMPLETED',
+ }),
+ })
+ );
- expect(events).toHaveLength(0);
+ expect(events).toHaveLength(2);
+ }).toPass({ timeout: 60_000 });
});
});