diff --git a/infrastructure/terraform/components/sbx/README.md b/infrastructure/terraform/components/sbx/README.md
index 2a33f10cf0..ddc41313de 100644
--- a/infrastructure/terraform/components/sbx/README.md
+++ b/infrastructure/terraform/components/sbx/README.md
@@ -43,6 +43,7 @@
| [events\_sns\_topic\_arn](#output\_events\_sns\_topic\_arn) | n/a |
| [internal\_bucket\_name](#output\_internal\_bucket\_name) | n/a |
| [letter\_variants\_table\_name](#output\_letter\_variants\_table\_name) | n/a |
+| [proof\_requests\_table\_name](#output\_proof\_requests\_table\_name) | n/a |
| [quarantine\_bucket\_name](#output\_quarantine\_bucket\_name) | n/a |
| [request\_proof\_queue\_url](#output\_request\_proof\_queue\_url) | n/a |
| [routing\_config\_table\_name](#output\_routing\_config\_table\_name) | n/a |
diff --git a/infrastructure/terraform/components/sbx/outputs.tf b/infrastructure/terraform/components/sbx/outputs.tf
index dee600631b..0a55b6eb20 100644
--- a/infrastructure/terraform/components/sbx/outputs.tf
+++ b/infrastructure/terraform/components/sbx/outputs.tf
@@ -74,6 +74,10 @@ output "events_sns_topic_arn" {
value = module.eventpub.sns_topic.arn
}
+output "proof_requests_table_name" {
+ value = module.backend_api.proof_requests_table_name
+}
+
output "letter_variants_table_name" {
value = module.backend_api.letter_variants_table_name
}
diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md
index 565790689c..9ad804b392 100644
--- a/infrastructure/terraform/modules/backend-api/README.md
+++ b/infrastructure/terraform/modules/backend-api/README.md
@@ -71,6 +71,7 @@ No requirements.
| [s3bucket\_internal](#module\_s3bucket\_internal) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.4/terraform-s3bucket.zip | n/a |
| [s3bucket\_quarantine](#module\_s3bucket\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.4/terraform-s3bucket.zip | n/a |
| [sqs\_letter\_render](#module\_sqs\_letter\_render) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-sqs.zip | n/a |
+| [sqs\_proof\_requests\_table\_events\_pipe\_dlq](#module\_sqs\_proof\_requests\_table\_events\_pipe\_dlq) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a |
| [sqs\_routing\_config\_table\_events\_pipe\_dlq](#module\_sqs\_routing\_config\_table\_events\_pipe\_dlq) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-sqs.zip | n/a |
| [sqs\_sftp\_upload](#module\_sqs\_sftp\_upload) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-sqs.zip | n/a |
| [sqs\_template\_mgmt\_events](#module\_sqs\_template\_mgmt\_events) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-sqs.zip | n/a |
@@ -92,6 +93,7 @@ No requirements.
| [download\_bucket\_regional\_domain\_name](#output\_download\_bucket\_regional\_domain\_name) | n/a |
| [internal\_bucket\_name](#output\_internal\_bucket\_name) | n/a |
| [letter\_variants\_table\_name](#output\_letter\_variants\_table\_name) | n/a |
+| [proof\_requests\_table\_name](#output\_proof\_requests\_table\_name) | n/a |
| [quarantine\_bucket\_name](#output\_quarantine\_bucket\_name) | n/a |
| [request\_proof\_queue\_url](#output\_request\_proof\_queue\_url) | n/a |
| [routing\_config\_table\_name](#output\_routing\_config\_table\_name) | n/a |
diff --git a/infrastructure/terraform/modules/backend-api/cloudwatch_log_group_pipe_proof_requests_table_events.tf b/infrastructure/terraform/modules/backend-api/cloudwatch_log_group_pipe_proof_requests_table_events.tf
new file mode 100644
index 0000000000..e652d480c3
--- /dev/null
+++ b/infrastructure/terraform/modules/backend-api/cloudwatch_log_group_pipe_proof_requests_table_events.tf
@@ -0,0 +1,5 @@
+resource "aws_cloudwatch_log_group" "pipe_proof_requests_table_events" {
+ name = "/aws/vendedlogs/pipes/${local.csi}-proof-requests-table-events"
+ kms_key_id = var.kms_key_arn
+ retention_in_days = var.log_retention_in_days
+}
diff --git a/infrastructure/terraform/modules/backend-api/dynamodb_table_proof_requests.tf b/infrastructure/terraform/modules/backend-api/dynamodb_table_proof_requests.tf
new file mode 100644
index 0000000000..2c2a528182
--- /dev/null
+++ b/infrastructure/terraform/modules/backend-api/dynamodb_table_proof_requests.tf
@@ -0,0 +1,40 @@
+resource "aws_dynamodb_table" "proof_requests" {
+ name = "${local.csi}-proof-requests"
+ billing_mode = "PAY_PER_REQUEST"
+
+ hash_key = "owner"
+ range_key = "id"
+
+ attribute {
+ name = "owner"
+ type = "S"
+ }
+
+ attribute {
+ name = "id"
+ type = "S"
+ }
+
+ ttl {
+ attribute_name = "ttl"
+ enabled = true
+ }
+
+ point_in_time_recovery {
+ enabled = true
+ }
+
+ server_side_encryption {
+ enabled = true
+ kms_key_arn = var.kms_key_arn
+ }
+
+ lifecycle {
+ ignore_changes = [
+ name,
+ ]
+ }
+
+ stream_enabled = true
+ stream_view_type = "NEW_AND_OLD_IMAGES"
+}
diff --git a/infrastructure/terraform/modules/backend-api/module_lambda_event_publisher.tf b/infrastructure/terraform/modules/backend-api/module_lambda_event_publisher.tf
index 8ab88e03b6..097ea63689 100644
--- a/infrastructure/terraform/modules/backend-api/module_lambda_event_publisher.tf
+++ b/infrastructure/terraform/modules/backend-api/module_lambda_event_publisher.tf
@@ -26,6 +26,7 @@ module "lambda_event_publisher" {
lambda_env_vars = {
EVENT_SOURCE = "//notify.nhs.uk/${var.component}/${var.group}/${var.environment}"
+ PROOF_REQUESTS_TABLE_NAME = aws_dynamodb_table.proof_requests.name
ROUTING_CONFIG_TABLE_NAME = aws_dynamodb_table.routing_configuration.name
SNS_TOPIC_ARN = coalesce(var.sns_topic_arn, aws_sns_topic.main.arn)
TEMPLATES_TABLE_NAME = aws_dynamodb_table.templates.name
diff --git a/infrastructure/terraform/modules/backend-api/module_sqs_proof_requests_table_events_pipe_dlq.tf b/infrastructure/terraform/modules/backend-api/module_sqs_proof_requests_table_events_pipe_dlq.tf
new file mode 100644
index 0000000000..5cb8512797
--- /dev/null
+++ b/infrastructure/terraform/modules/backend-api/module_sqs_proof_requests_table_events_pipe_dlq.tf
@@ -0,0 +1,12 @@
+module "sqs_proof_requests_table_events_pipe_dlq" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "proof-requests-table-events-pipe-dead-letter"
+ sqs_kms_key_arn = var.kms_key_arn
+ message_retention_seconds = 1209600
+}
diff --git a/infrastructure/terraform/modules/backend-api/outputs.tf b/infrastructure/terraform/modules/backend-api/outputs.tf
index e5d30a2870..7e10f02ee9 100644
--- a/infrastructure/terraform/modules/backend-api/outputs.tf
+++ b/infrastructure/terraform/modules/backend-api/outputs.tf
@@ -46,6 +46,10 @@ output "routing_config_table_name" {
value = aws_dynamodb_table.routing_configuration.name
}
+output "proof_requests_table_name" {
+ value = aws_dynamodb_table.proof_requests.name
+}
+
output "letter_variants_table_name" {
value = aws_dynamodb_table.letter_variants.name
}
diff --git a/infrastructure/terraform/modules/backend-api/pipes_pipe_proof_requests_table_events.tf b/infrastructure/terraform/modules/backend-api/pipes_pipe_proof_requests_table_events.tf
new file mode 100644
index 0000000000..16551e0d3e
--- /dev/null
+++ b/infrastructure/terraform/modules/backend-api/pipes_pipe_proof_requests_table_events.tf
@@ -0,0 +1,113 @@
+resource "aws_pipes_pipe" "proof_requests_table_events" {
+ depends_on = [module.sqs_proof_requests_table_events_pipe_dlq]
+
+ name = "${local.csi}-proof-requests-table-events"
+ role_arn = aws_iam_role.pipe_proof_requests_table_events.arn
+ source = aws_dynamodb_table.proof_requests.stream_arn
+ target = module.sqs_template_mgmt_events.sqs_queue_arn
+ desired_state = "RUNNING"
+ kms_key_identifier = var.kms_key_arn
+
+ source_parameters {
+ dynamodb_stream_parameters {
+ starting_position = "TRIM_HORIZON"
+ on_partial_batch_item_failure = "AUTOMATIC_BISECT"
+ batch_size = 10
+ maximum_batching_window_in_seconds = 5
+ maximum_retry_attempts = 5
+ maximum_record_age_in_seconds = -1
+
+ dead_letter_config {
+ arn = module.sqs_proof_requests_table_events_pipe_dlq.sqs_queue_arn
+ }
+ }
+ }
+
+ target_parameters {
+ input_template = <<-EOF
+ {
+ "dynamodb": <$.dynamodb>,
+ "eventID": <$.eventID>,
+ "eventName": <$.eventName>,
+ "eventSource": <$.eventSource>,
+ "tableName": "${aws_dynamodb_table.proof_requests.name}"
+ }
+ EOF
+
+ sqs_queue_parameters {
+ message_group_id = "$.dynamodb.Keys.id.S"
+ message_deduplication_id = "$.eventID"
+ }
+ }
+
+ log_configuration {
+ level = "ERROR"
+ include_execution_data = ["ALL"]
+
+ cloudwatch_logs_log_destination {
+ log_group_arn = aws_cloudwatch_log_group.pipe_proof_requests_table_events.arn
+ }
+ }
+}
+
+resource "aws_iam_role" "pipe_proof_requests_table_events" {
+ name = "${local.csi}-pipe-proof-requests-table-events"
+ description = "IAM Role for Pipe to forward proof requests table stream events to SQS"
+ assume_role_policy = data.aws_iam_policy_document.pipes_proof_requests_trust_policy.json
+}
+
+data "aws_iam_policy_document" "pipes_proof_requests_trust_policy" {
+ statement {
+ sid = "PipesAssumeRole"
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["pipes.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role_policy" "pipe_proof_requests_table_events" {
+ name = "${local.csi}-pipe-proof-requests-table-events"
+ role = aws_iam_role.pipe_proof_requests_table_events.id
+ policy = data.aws_iam_policy_document.pipe_proof_requests_table_events.json
+}
+
+data "aws_iam_policy_document" "pipe_proof_requests_table_events" {
+ version = "2012-10-17"
+
+ statement {
+ sid = "AllowDynamoStreamRead"
+ effect = "Allow"
+ actions = [
+ "dynamodb:DescribeStream",
+ "dynamodb:GetRecords",
+ "dynamodb:GetShardIterator",
+ "dynamodb:ListStreams",
+ ]
+ resources = [aws_dynamodb_table.proof_requests.stream_arn]
+ }
+
+ statement {
+ sid = "AllowSqsSendMessage"
+ effect = "Allow"
+ actions = ["sqs:SendMessage"]
+ resources = [
+ module.sqs_template_mgmt_events.sqs_queue_arn,
+ module.sqs_proof_requests_table_events_pipe_dlq.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowKmsUsage"
+ effect = "Allow"
+ actions = [
+ "kms:Decrypt",
+ "kms:Encrypt",
+ "kms:GenerateDataKey*"
+ ]
+ resources = [var.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 ab5ba1615c..262999a91b 100644
--- a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts
+++ b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts
@@ -23,11 +23,13 @@ const { logger: mockLogger } = createMockLogger();
const tables = {
templates: 'templates-table',
routing: 'routing-config-table',
+ proofRequests: 'proof-request-table',
};
const eventBuilder = new EventBuilder(
tables.templates,
tables.routing,
+ tables.proofRequests,
'event-source',
mockLogger
);
@@ -378,6 +380,67 @@ const expectedRoutingConfigEvent = (
},
});
+const publishableProofRequestEventRecord = (): PublishableEventRecord => ({
+ dynamodb: {
+ SequenceNumber: '4',
+ NewImage: {
+ id: {
+ S: '92b676e9-470f-4d04-ab14-965ef145e15d',
+ },
+ templateId: {
+ S: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd',
+ },
+ templateType: {
+ S: 'SMS',
+ },
+ testPatientNhsNumber: {
+ S: '9000000009',
+ },
+ contactDetails: {
+ M: {
+ sms: {
+ S: '07700900000',
+ },
+ },
+ },
+ personalisation: {
+ M: {
+ firstName: {
+ S: 'Jane',
+ },
+ },
+ },
+ },
+ },
+ eventID: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d',
+ tableName: tables.proofRequests,
+});
+
+const expectedProofRequestedEvent = () => ({
+ id: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d',
+ datacontenttype: 'application/json',
+ time: '2022-01-01T09:00:00.000Z',
+ source: 'event-source',
+ type: 'uk.nhs.notify.template-management.ProofRequested.v1',
+ specversion: '1.0',
+ dataschema: 'https://notify.nhs.uk/events/schemas/ProofRequested/v1.json',
+ dataschemaversion: VERSION,
+ plane: 'data',
+ subject: '92b676e9-470f-4d04-ab14-965ef145e15d',
+ data: {
+ id: '92b676e9-470f-4d04-ab14-965ef145e15d',
+ templateId: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd',
+ templateType: 'SMS',
+ testPatientNhsNumber: '9000000009',
+ contactDetails: {
+ sms: '07700900000',
+ },
+ personalisation: {
+ firstName: 'Jane',
+ },
+ },
+});
+
test('errors on unrecognised event table source', () => {
const invalidpublishableTemplateEventRecord = {
...publishableTemplateEventRecord('SUBMITTED'),
@@ -634,3 +697,54 @@ describe('routing config events', () => {
expect(event).toEqual(undefined);
});
});
+
+describe('proof request events', () => {
+ test('builds proof requested event', () => {
+ const event = eventBuilder.buildEvent(publishableProofRequestEventRecord());
+
+ expect(event).toEqual(expectedProofRequestedEvent());
+ });
+
+ test('errors on output schema validation failure after input parsing', () => {
+ const valid = publishableProofRequestEventRecord();
+
+ const invalidDomainEventRecord: PublishableEventRecord = {
+ ...valid,
+ dynamodb: {
+ ...valid.dynamodb,
+ NewImage: {
+ ...valid.dynamodb.NewImage,
+ templateType: { S: 'EMAIL' },
+ },
+ },
+ };
+
+ expect(() => eventBuilder.buildEvent(invalidDomainEventRecord)).toThrow(
+ expect.objectContaining({
+ name: 'ZodError',
+ issues: [
+ expect.objectContaining({
+ code: 'invalid_type',
+ path: ['data', 'contactDetails', 'email'],
+ }),
+ ],
+ })
+ );
+ });
+
+ test('does not build proof request event on hard delete', () => {
+ const hardDeletePublishableProofRequestEventRecord = {
+ ...publishableProofRequestEventRecord(),
+ dynamodb: {
+ SequenceNumber: '4',
+ NewImage: undefined,
+ },
+ };
+
+ const event = eventBuilder.buildEvent(
+ hardDeletePublishableProofRequestEventRecord
+ );
+
+ expect(event).toEqual(undefined);
+ });
+});
diff --git a/lambdas/event-publisher/src/config.ts b/lambdas/event-publisher/src/config.ts
index 977ec26604..85aca7d405 100644
--- a/lambdas/event-publisher/src/config.ts
+++ b/lambdas/event-publisher/src/config.ts
@@ -5,6 +5,7 @@ const $Config = z.object({
ROUTING_CONFIG_TABLE_NAME: z.string(),
SNS_TOPIC_ARN: z.string(),
TEMPLATES_TABLE_NAME: z.string(),
+ PROOF_REQUESTS_TABLE_NAME: z.string(),
});
export const loadConfig = () => {
diff --git a/lambdas/event-publisher/src/container.ts b/lambdas/event-publisher/src/container.ts
index 3199d25cd9..29ff65db00 100644
--- a/lambdas/event-publisher/src/container.ts
+++ b/lambdas/event-publisher/src/container.ts
@@ -11,6 +11,7 @@ export const createContainer = () => {
ROUTING_CONFIG_TABLE_NAME,
SNS_TOPIC_ARN,
TEMPLATES_TABLE_NAME,
+ PROOF_REQUESTS_TABLE_NAME,
} = loadConfig();
const snsClient = new SNSClient({ region: 'eu-west-2' });
@@ -20,6 +21,7 @@ export const createContainer = () => {
const eventBuilder = new EventBuilder(
TEMPLATES_TABLE_NAME,
ROUTING_CONFIG_TABLE_NAME,
+ PROOF_REQUESTS_TABLE_NAME,
EVENT_SOURCE,
logger
);
diff --git a/lambdas/event-publisher/src/domain/event-builder.ts b/lambdas/event-publisher/src/domain/event-builder.ts
index d6d4013335..12428cc325 100644
--- a/lambdas/event-publisher/src/domain/event-builder.ts
+++ b/lambdas/event-publisher/src/domain/event-builder.ts
@@ -5,6 +5,7 @@ import {
} from '@nhsdigital/nhs-notify-event-schemas-template-management';
import { Logger } from 'nhs-notify-web-template-management-utils/logger';
import {
+ $DynamoDBProofRequest,
$DynamoDBRoutingConfig,
$DynamoDBTemplate,
PublishableEventRecord,
@@ -16,6 +17,7 @@ export class EventBuilder {
constructor(
private readonly templatesTableName: string,
private readonly routingConfigTableName: string,
+ private readonly proofRequestsTableName: string,
private readonly eventSource: string,
private readonly logger: Logger
) {}
@@ -52,14 +54,19 @@ export class EventBuilder {
}
}
- private buildEventMetadata(id: string, type: string, subject: string) {
+ private buildEventMetadata(
+ id: string,
+ type: string,
+ subject: string,
+ plane: 'control' | 'data' = 'control'
+ ) {
return {
id,
datacontenttype: 'application/json',
time: new Date().toISOString(),
source: this.eventSource,
specversion: '1.0',
- plane: 'control',
+ plane,
subject,
type: `uk.nhs.notify.template-management.${type}.v${MAJOR_VERSION}`,
dataschema: `https://notify.nhs.uk/events/schemas/${type}/v${MAJOR_VERSION}.json`,
@@ -165,6 +172,45 @@ export class EventBuilder {
return event.data;
}
+ private buildProofRequestedEvent(
+ publishableEventRecord: PublishableEventRecord
+ ): Event | undefined {
+ if (!publishableEventRecord.dynamodb.NewImage) {
+ // Do not publish an event if a proof-request record is deleted
+ this.logger.debug({
+ description: 'No new image found',
+ publishableEventRecord,
+ });
+
+ return undefined;
+ }
+
+ const dynamoRecord = unmarshall(publishableEventRecord.dynamodb.NewImage);
+
+ const databaseProofRequest = $DynamoDBProofRequest.parse(dynamoRecord);
+
+ try {
+ return $Event.parse({
+ ...this.buildEventMetadata(
+ publishableEventRecord.eventID,
+ 'ProofRequested',
+ databaseProofRequest.id,
+ 'data'
+ ),
+ data: dynamoRecord,
+ });
+ } catch (error) {
+ this.logger
+ .child({
+ description: 'Failed to parse outgoing event for proof request',
+ publishableEventRecord,
+ })
+ .error(error);
+
+ throw error;
+ }
+ }
+
buildEvent(
publishableEventRecord: PublishableEventRecord
): Event | undefined {
@@ -175,6 +221,9 @@ export class EventBuilder {
case this.routingConfigTableName: {
return this.buildRoutingConfigDatabaseEvent(publishableEventRecord);
}
+ case this.proofRequestsTableName: {
+ return this.buildProofRequestedEvent(publishableEventRecord);
+ }
default: {
this.logger.error({
description: 'Unrecognised event type',
diff --git a/lambdas/event-publisher/src/domain/input-schemas.ts b/lambdas/event-publisher/src/domain/input-schemas.ts
index 0bd8a53071..1a42762eed 100644
--- a/lambdas/event-publisher/src/domain/input-schemas.ts
+++ b/lambdas/event-publisher/src/domain/input-schemas.ts
@@ -53,6 +53,9 @@ export const $DynamoDBRoutingConfig = schemaFor>()(
);
export type DynamoDBRoutingConfig = z.infer;
+export const $DynamoDBProofRequest = z.object({ id: z.string() });
+export type DynamoDBProofRequest = 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 e43f8b2671..17c257c09c 100644
--- a/lambdas/event-publisher/src/domain/output-schemas.ts
+++ b/lambdas/event-publisher/src/domain/output-schemas.ts
@@ -1,4 +1,5 @@
import {
+ $ProofRequestedEventV1,
$RoutingConfigCompletedEventV1,
$RoutingConfigDeletedEventV1,
$RoutingConfigDraftedEventV1,
@@ -9,11 +10,12 @@ import {
import { z } from 'zod';
export const $Event = z.discriminatedUnion('type', [
- $TemplateCompletedEventV1,
- $TemplateDraftedEventV1,
- $TemplateDeletedEventV1,
+ $ProofRequestedEventV1,
$RoutingConfigCompletedEventV1,
- $RoutingConfigDraftedEventV1,
$RoutingConfigDeletedEventV1,
+ $RoutingConfigDraftedEventV1,
+ $TemplateCompletedEventV1,
+ $TemplateDeletedEventV1,
+ $TemplateDraftedEventV1,
]);
export type Event = z.infer;
diff --git a/package-lock.json b/package-lock.json
index df9bab624b..f2fb5ce67f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7814,13 +7814,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.27",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.27.tgz",
- "integrity": "sha512-t3ehEtHomGZwg5Gixw4fYbYtG9JBnjfAjSDabxhPEu/KLLUp0BB37/APX7MSKXQhX6ZH7pseuACFJ19NrAkNdg==",
+ "version": "3.972.28",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz",
+ "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.26",
- "@aws-sdk/nested-clients": "^3.996.17",
+ "@aws-sdk/nested-clients": "^3.996.18",
"@aws-sdk/types": "^3.973.6",
"@smithy/property-provider": "^4.2.12",
"@smithy/protocol-http": "^5.3.12",
@@ -7902,9 +7902,9 @@
}
},
"node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.972.27",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.27.tgz",
- "integrity": "sha512-TIRLO5UR2+FVUGmhYoAwVkKhcVzywEDX/5LzR9tjy1h8FQAXOtFg2IqgmwvxU7y933rkTn9rl6AdgcAUgQ1/Kg==",
+ "version": "3.972.28",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz",
+ "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.26",
@@ -7913,7 +7913,7 @@
"@smithy/core": "^3.23.13",
"@smithy/protocol-http": "^5.3.12",
"@smithy/types": "^4.13.1",
- "@smithy/util-retry": "^4.2.12",
+ "@smithy/util-retry": "^4.2.13",
"tslib": "^2.6.2"
},
"engines": {
@@ -7921,9 +7921,9 @@
}
},
"node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": {
- "version": "3.996.17",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.17.tgz",
- "integrity": "sha512-7B0HIX0tEFmOSJuWzdHZj1WhMXSryM+h66h96ZkqSncoY7J6wq61KOu4Kr57b/YnJP3J/EeQYVFulgR281h+7A==",
+ "version": "3.996.18",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz",
+ "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
@@ -7932,12 +7932,12 @@
"@aws-sdk/middleware-host-header": "^3.972.8",
"@aws-sdk/middleware-logger": "^3.972.8",
"@aws-sdk/middleware-recursion-detection": "^3.972.9",
- "@aws-sdk/middleware-user-agent": "^3.972.27",
+ "@aws-sdk/middleware-user-agent": "^3.972.28",
"@aws-sdk/region-config-resolver": "^3.972.10",
"@aws-sdk/types": "^3.973.6",
"@aws-sdk/util-endpoints": "^3.996.5",
"@aws-sdk/util-user-agent-browser": "^3.972.8",
- "@aws-sdk/util-user-agent-node": "^3.973.13",
+ "@aws-sdk/util-user-agent-node": "^3.973.14",
"@smithy/config-resolver": "^4.4.13",
"@smithy/core": "^3.23.13",
"@smithy/fetch-http-handler": "^5.3.15",
@@ -7945,7 +7945,7 @@
"@smithy/invalid-dependency": "^4.2.12",
"@smithy/middleware-content-length": "^4.2.12",
"@smithy/middleware-endpoint": "^4.4.28",
- "@smithy/middleware-retry": "^4.4.45",
+ "@smithy/middleware-retry": "^4.4.46",
"@smithy/middleware-serde": "^4.2.16",
"@smithy/middleware-stack": "^4.2.12",
"@smithy/node-config-provider": "^4.3.12",
@@ -7961,7 +7961,7 @@
"@smithy/util-defaults-mode-node": "^4.2.48",
"@smithy/util-endpoints": "^3.3.3",
"@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
+ "@smithy/util-retry": "^4.2.13",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -8027,12 +8027,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.973.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.13.tgz",
- "integrity": "sha512-s1dCJ0J9WU9UPkT3FFqhKTSquYTkqWXGRaapHFyWwwJH86ZussewhNST5R5TwXVL1VSHq4aJVl9fWK+svaRVCQ==",
+ "version": "3.973.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz",
+ "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-user-agent": "^3.972.27",
+ "@aws-sdk/middleware-user-agent": "^3.972.28",
"@aws-sdk/types": "^3.973.6",
"@smithy/node-config-provider": "^4.3.12",
"@smithy/types": "^4.13.1",
@@ -8075,9 +8075,9 @@
}
},
"node_modules/@aws-sdk/credential-provider-login/node_modules/fast-xml-parser": {
- "version": "5.5.9",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
- "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
+ "version": "5.5.6",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
+ "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
"funding": [
{
"type": "github",
@@ -8087,8 +8087,8 @@
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
- "path-expression-matcher": "^1.2.0",
- "strnum": "^2.2.2"
+ "path-expression-matcher": "^1.1.3",
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -13230,9 +13230,9 @@
}
},
"node_modules/@smithy/middleware-retry": {
- "version": "4.4.45",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.45.tgz",
- "integrity": "sha512-td1PxpwDIaw5/oP/xIRxBGxJKoF1L4DBAwbZ8wjMuXBYOP/r2ZE/Ocou+mBHx/yk9knFEtDBwhSrYVn+Mz4pHw==",
+ "version": "4.4.46",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz",
+ "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.12",
@@ -13241,7 +13241,7 @@
"@smithy/smithy-client": "^4.12.8",
"@smithy/types": "^4.13.1",
"@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
+ "@smithy/util-retry": "^4.2.13",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
},
@@ -13584,9 +13584,9 @@
}
},
"node_modules/@smithy/util-retry": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz",
- "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==",
+ "version": "4.2.13",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz",
+ "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/service-error-classification": "^4.2.12",
@@ -28017,7 +28017,7 @@
},
"packages/event-schemas": {
"name": "@nhsdigital/nhs-notify-event-schemas-template-management",
- "version": "1.4.4",
+ "version": "1.4.5",
"license": "MIT",
"dependencies": {
"zod": "^4.0.17"
diff --git a/package.json b/package.json
index 16f461361c..7cef783ed5 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,10 @@
"react-is": "19.0.0"
},
"react": "^19.0.0",
- "underscore": "^1.13.8"
+ "underscore": "^1.13.8",
+ "@aws-sdk/xml-builder": {
+ "fast-xml-parser": "5.5.6"
+ }
},
"scripts": {
"build": "npm run create-env-file && npm run build --workspace frontend",
diff --git a/packages/event-schemas/README.md b/packages/event-schemas/README.md
index 11e0212065..b64b044f9c 100644
--- a/packages/event-schemas/README.md
+++ b/packages/event-schemas/README.md
@@ -19,6 +19,10 @@ Then run `npm install @nhsdigital/nhs-notify-event-schemas-template-management`
## Events
+- `ProofRequested`
+- `RoutingConfigCompleted`
+- `RoutingConfigDeleted`
+- `RoutingConfigDrafted`
- `TemplateCompleted`
- `TemplateDeleted`
- `TemplateDrafted`
diff --git a/packages/event-schemas/__tests__/events/proof-requested.test.ts b/packages/event-schemas/__tests__/events/proof-requested.test.ts
new file mode 100644
index 0000000000..4ae9f4f944
--- /dev/null
+++ b/packages/event-schemas/__tests__/events/proof-requested.test.ts
@@ -0,0 +1,77 @@
+/* eslint-disable security/detect-non-literal-fs-filename */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { $ProofRequestedEventV1 } from '../../src/events/proof-requested';
+
+const examplesDir = path.resolve(__dirname, '../../examples/ProofRequested/v1');
+
+const createEvent = (type, contactDetails) => ({
+ data: {
+ id: 'c3d4e5f6-a7b8-4012-8def-123456789012',
+ templateId: '8c7ae592-97cd-4900-897e-ef4794c8a745',
+ templateType: type,
+ testPatientNhsNumber: '9000000009',
+ contactDetails: contactDetails,
+ personalisation: {
+ firstName: 'Jane',
+ surgeryName: 'Test Surgery',
+ },
+ },
+ datacontenttype: 'application/json',
+ dataschema: 'https://notify.nhs.uk/events/schemas/ProofRequested/v1.json',
+ dataschemaversion: '1.0.0',
+ id: 'b3c4d5e6-f7a8-9012-bcde-f12345678902',
+ plane: 'data',
+ source: '//notify.nhs.uk/app/nhs-notify-template-management-prod/main',
+ specversion: '1.0',
+ subject: 'c3d4e5f6-a7b8-4012-8def-123456789012',
+ time: '2025-07-29T10:05:45.145Z',
+ type: 'uk.nhs.notify.template-management.ProofRequested.v1',
+});
+
+describe('ProofRequestedEventV1 Zod schema', () => {
+ it.each(fs.readdirSync(examplesDir))(
+ 'parses sample event %s without errors',
+ (filename) => {
+ const event = JSON.parse(
+ fs.readFileSync(path.join(examplesDir, filename), 'utf8')
+ );
+
+ const result = $ProofRequestedEventV1.safeParse(event);
+
+ if (!result.success) {
+ console.log(result.error);
+ }
+
+ expect(result.success).toBe(true);
+ }
+ );
+
+ test.each([
+ ['SMS', { sms: undefined }],
+ ['EMAIL', { email: undefined }],
+ ])(
+ 'fails when %s ProofRequestedEventV1 contactDetails has %s',
+ (type, contactDetails) => {
+ const event = createEvent(type, contactDetails);
+
+ const { error, success } = $ProofRequestedEventV1.safeParse(event);
+
+ expect(success).toBe(false);
+
+ expect(error).toBeDefined();
+
+ expect(error!.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ expected: 'string',
+ code: 'invalid_type',
+ path: ['data', 'contactDetails', type.toLowerCase()],
+ message: 'Invalid input: expected string, received undefined',
+ }),
+ ])
+ );
+ }
+ );
+});
diff --git a/packages/event-schemas/__tests__/json-schemas/proof-requested.test.ts b/packages/event-schemas/__tests__/json-schemas/proof-requested.test.ts
new file mode 100644
index 0000000000..aac9ab0bb4
--- /dev/null
+++ b/packages/event-schemas/__tests__/json-schemas/proof-requested.test.ts
@@ -0,0 +1,33 @@
+/* eslint-disable security/detect-non-literal-fs-filename */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { Ajv2020 } from 'ajv/dist/2020';
+import addFormats from 'ajv-formats';
+
+import ProofRequestedEventV1Schema from '../../schemas/ProofRequested/v1.json';
+
+const examplesDir = path.resolve(__dirname, '../../examples/ProofRequested/v1');
+
+describe('ProofRequestedEventV1 JSON schema', () => {
+ it.each(fs.readdirSync(examplesDir))(
+ 'parses sample event %s without errors',
+ (filename) => {
+ const event = JSON.parse(
+ fs.readFileSync(path.join(examplesDir, filename), 'utf8')
+ );
+
+ const ajv = new Ajv2020();
+ addFormats(ajv);
+ const validate = ajv.compile(ProofRequestedEventV1Schema);
+
+ const valid = validate(event);
+
+ if (!valid) {
+ console.log(validate.errors);
+ }
+
+ expect(valid).toBe(true);
+ }
+ );
+});
diff --git a/packages/event-schemas/examples/ProofRequested/v1/email.json b/packages/event-schemas/examples/ProofRequested/v1/email.json
new file mode 100644
index 0000000000..63fa0783b4
--- /dev/null
+++ b/packages/event-schemas/examples/ProofRequested/v1/email.json
@@ -0,0 +1,25 @@
+{
+ "data": {
+ "id": "c3d4e5f6-a7b8-4012-8def-123456789012",
+ "templateId": "8c7ae592-97cd-4900-897e-ef4794c8a745",
+ "templateType": "EMAIL",
+ "testPatientNhsNumber": "9000000009",
+ "contactDetails": {
+ "email": "test.patient@example.com"
+ },
+ "personalisation": {
+ "firstName": "Jane",
+ "surgeryName": "Test Surgery"
+ }
+ },
+ "datacontenttype": "application/json",
+ "dataschema": "https://notify.nhs.uk/events/schemas/ProofRequested/v1.json",
+ "dataschemaversion": "1.0.0",
+ "id": "b3c4d5e6-f7a8-9012-bcde-f12345678902",
+ "plane": "data",
+ "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main",
+ "specversion": "1.0",
+ "subject": "c3d4e5f6-a7b8-4012-8def-123456789012",
+ "time": "2025-07-29T10:05:45.145Z",
+ "type": "uk.nhs.notify.template-management.ProofRequested.v1"
+}
diff --git a/packages/event-schemas/examples/ProofRequested/v1/nhsapp.json b/packages/event-schemas/examples/ProofRequested/v1/nhsapp.json
new file mode 100644
index 0000000000..0636008777
--- /dev/null
+++ b/packages/event-schemas/examples/ProofRequested/v1/nhsapp.json
@@ -0,0 +1,24 @@
+{
+ "data": {
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "templateId": "3a58a370-75ab-4788-a75e-cd2572a68523",
+ "templateType": "NHS_APP",
+ "testPatientNhsNumber": "9000000009",
+ "personalisation": {
+ "firstName": "Jane",
+ "appointmentTime": "10:00",
+ "appointmentDate": "2025-08-01",
+ "surgeryName": "Test Surgery"
+ }
+ },
+ "datacontenttype": "application/json",
+ "dataschema": "https://notify.nhs.uk/events/schemas/ProofRequested/v1.json",
+ "dataschemaversion": "1.0.0",
+ "id": "f1e2d3c4-b5a6-7890-fedc-ba0987654321",
+ "plane": "data",
+ "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main",
+ "specversion": "1.0",
+ "subject": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "time": "2025-07-29T10:05:45.145Z",
+ "type": "uk.nhs.notify.template-management.ProofRequested.v1"
+}
diff --git a/packages/event-schemas/examples/ProofRequested/v1/sms.json b/packages/event-schemas/examples/ProofRequested/v1/sms.json
new file mode 100644
index 0000000000..a80d05afcb
--- /dev/null
+++ b/packages/event-schemas/examples/ProofRequested/v1/sms.json
@@ -0,0 +1,24 @@
+{
+ "data": {
+ "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+ "templateId": "7b69c481-86bc-5899-b86f-de3683b79634",
+ "templateType": "SMS",
+ "testPatientNhsNumber": "9000000009",
+ "contactDetails": {
+ "sms": "07700900000"
+ },
+ "personalisation": {
+ "firstName": "Jane"
+ }
+ },
+ "datacontenttype": "application/json",
+ "dataschema": "https://notify.nhs.uk/events/schemas/ProofRequested/v1.json",
+ "dataschemaversion": "1.0.0",
+ "id": "a2b3c4d5-e6f7-8901-abcd-ef1234567891",
+ "plane": "data",
+ "source": "//notify.nhs.uk/app/nhs-notify-template-management-prod/main",
+ "specversion": "1.0",
+ "subject": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+ "time": "2025-07-29T10:05:45.145Z",
+ "type": "uk.nhs.notify.template-management.ProofRequested.v1"
+}
diff --git a/packages/event-schemas/package.json b/packages/event-schemas/package.json
index 32a6a1f3cb..60080f05be 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.4.4"
+ "version": "1.4.5"
}
diff --git a/packages/event-schemas/schemas/ProofRequested/v1.json b/packages/event-schemas/schemas/ProofRequested/v1.json
new file mode 100644
index 0000000000..6b4630f3f4
--- /dev/null
+++ b/packages/event-schemas/schemas/ProofRequested/v1.json
@@ -0,0 +1,221 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "maxLength": 1000,
+ "description": "Unique ID for this event"
+ },
+ "time": {
+ "type": "string",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z$",
+ "description": "Time the event was generated"
+ },
+ "type": {
+ "type": "string",
+ "const": "uk.nhs.notify.template-management.ProofRequested.v1"
+ },
+ "source": {
+ "type": "string",
+ "description": "Source of the event"
+ },
+ "specversion": {
+ "type": "string",
+ "const": "1.0",
+ "description": "Version of the envelope event schema"
+ },
+ "datacontenttype": {
+ "type": "string",
+ "const": "application/json",
+ "description": "Always application/json"
+ },
+ "subject": {
+ "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|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
+ "description": "Unique identifier for the item in the event data"
+ },
+ "dataschema": {
+ "type": "string",
+ "const": "https://notify.nhs.uk/events/schemas/ProofRequested/v1.json"
+ },
+ "dataschemaversion": {
+ "type": "string",
+ "pattern": "^1\\..*"
+ },
+ "plane": {
+ "type": "string",
+ "const": "data"
+ },
+ "data": {
+ "$ref": "#/$defs/ProofRequestEventData"
+ }
+ },
+ "required": [
+ "id",
+ "time",
+ "type",
+ "source",
+ "specversion",
+ "datacontenttype",
+ "subject",
+ "dataschema",
+ "dataschemaversion",
+ "plane",
+ "data"
+ ],
+ "$defs": {
+ "ProofRequestEventData": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier of the proof request"
+ },
+ "templateId": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier for the template being proofed"
+ },
+ "testPatientNhsNumber": {
+ "type": "string",
+ "description": "NHS number of test patient to use in the proofing request"
+ },
+ "personalisation": {
+ "description": "Personalisation fields to use in the proof",
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "templateType": {
+ "type": "string",
+ "const": "NHS_APP",
+ "description": "Template type for the template being proofed"
+ }
+ },
+ "required": [
+ "id",
+ "templateId",
+ "testPatientNhsNumber",
+ "templateType"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier of the proof request"
+ },
+ "templateId": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier for the template being proofed"
+ },
+ "testPatientNhsNumber": {
+ "type": "string",
+ "description": "NHS number of test patient to use in the proofing request"
+ },
+ "personalisation": {
+ "description": "Personalisation fields to use in the proof",
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "templateType": {
+ "type": "string",
+ "const": "SMS",
+ "description": "Template type for the template being proofed"
+ },
+ "contactDetails": {
+ "type": "object",
+ "properties": {
+ "sms": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sms"
+ ],
+ "description": "Contact details to send the proof to"
+ }
+ },
+ "required": [
+ "id",
+ "templateId",
+ "testPatientNhsNumber",
+ "templateType",
+ "contactDetails"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier of the proof request"
+ },
+ "templateId": {
+ "type": "string",
+ "pattern": "^[\\dA-Fa-f]{8}(?:-[\\dA-Fa-f]{4}){3}-[\\dA-Fa-f]{12}$",
+ "description": "Unique identifier for the template being proofed"
+ },
+ "testPatientNhsNumber": {
+ "type": "string",
+ "description": "NHS number of test patient to use in the proofing request"
+ },
+ "personalisation": {
+ "description": "Personalisation fields to use in the proof",
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "templateType": {
+ "type": "string",
+ "const": "EMAIL",
+ "description": "Template type for the template being proofed"
+ },
+ "contactDetails": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "description": "Contact details to send the proof to"
+ }
+ },
+ "required": [
+ "id",
+ "templateId",
+ "testPatientNhsNumber",
+ "templateType",
+ "contactDetails"
+ ]
+ }
+ ]
+ }
+ },
+ "$id": "https://notify.nhs.uk/events/schemas/ProofRequested/v1.json"
+}
diff --git a/packages/event-schemas/scripts/generate-json-schemas.ts b/packages/event-schemas/scripts/generate-json-schemas.ts
index 72ef759b53..ef5f6eab3d 100644
--- a/packages/event-schemas/scripts/generate-json-schemas.ts
+++ b/packages/event-schemas/scripts/generate-json-schemas.ts
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import {
+ $ProofRequestedEventV1,
$TemplateCompletedEventV1,
$TemplateDeletedEventV1,
$TemplateDraftedEventV1,
@@ -62,6 +63,13 @@ function writeSchema(
);
}
+writeSchema(
+ 'ProofRequested',
+ $ProofRequestedEventV1,
+ '1',
+ 'https://notify.nhs.uk/events/schemas/ProofRequested/v1.json'
+);
+
writeSchema(
'TemplateCompleted',
$TemplateCompletedEventV1,
diff --git a/packages/event-schemas/src/events/index.ts b/packages/event-schemas/src/events/index.ts
index fe40961634..eb1cc3332e 100644
--- a/packages/event-schemas/src/events/index.ts
+++ b/packages/event-schemas/src/events/index.ts
@@ -1,6 +1,7 @@
-export * from './template-completed';
-export * from './template-deleted';
-export * from './template-drafted';
+export * from './proof-requested';
export * from './routing-config-completed';
export * from './routing-config-deleted';
export * from './routing-config-drafted';
+export * from './template-completed';
+export * from './template-deleted';
+export * from './template-drafted';
diff --git a/packages/event-schemas/src/events/proof-requested.ts b/packages/event-schemas/src/events/proof-requested.ts
new file mode 100644
index 0000000000..a31fc02800
--- /dev/null
+++ b/packages/event-schemas/src/events/proof-requested.ts
@@ -0,0 +1,15 @@
+import { z } from 'zod';
+import { $NHSNotifyEventEnvelope } from '../event-envelope';
+import { $ProofRequestEventData } from '../proof-request';
+
+export const $ProofRequestedEventV1 = $NHSNotifyEventEnvelope.extend({
+ type: z.literal('uk.nhs.notify.template-management.ProofRequested.v1'),
+ dataschema: z.literal(
+ 'https://notify.nhs.uk/events/schemas/ProofRequested/v1.json'
+ ),
+ dataschemaversion: z.string().startsWith('1.'),
+ plane: z.literal('data'),
+ data: $ProofRequestEventData,
+});
+
+export type ProofRequestedEventV1 = z.infer;
diff --git a/packages/event-schemas/src/proof-request.ts b/packages/event-schemas/src/proof-request.ts
new file mode 100644
index 0000000000..2e49dcfb3f
--- /dev/null
+++ b/packages/event-schemas/src/proof-request.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod';
+
+// eslint-disable-next-line security/detect-unsafe-regex
+const UUID_REGEX = /^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/;
+
+const $BaseProofRequestEventData = z.object({
+ id: z.string().regex(UUID_REGEX).meta({
+ description: 'Unique identifier of the proof request',
+ }),
+ templateId: z.string().regex(UUID_REGEX).meta({
+ description: 'Unique identifier for the template being proofed',
+ }),
+ testPatientNhsNumber: z.string().meta({
+ description: 'NHS number of test patient to use in the proofing request',
+ }),
+ personalisation: z.record(z.string(), z.string()).optional().meta({
+ description: 'Personalisation fields to use in the proof',
+ }),
+});
+
+const $ProofRequestedContactDetailsSMS = z
+ .object({
+ sms: z.string(),
+ })
+ .meta({ description: 'Contact details to send the proof to' });
+
+const $ProofRequestedContactDetailsEmail = z
+ .object({
+ email: z.string(),
+ })
+ .meta({ description: 'Contact details to send the proof to' });
+
+export const $ProofRequestSMSEventData = $BaseProofRequestEventData.extend({
+ templateType: z.literal('SMS').meta({
+ description: 'Template type for the template being proofed',
+ }),
+ contactDetails: $ProofRequestedContactDetailsSMS,
+});
+
+export const $ProofRequestEmailEventData = $BaseProofRequestEventData.extend({
+ templateType: z.literal('EMAIL').meta({
+ description: 'Template type for the template being proofed',
+ }),
+ contactDetails: $ProofRequestedContactDetailsEmail,
+});
+
+export const $ProofRequestNHSAppEventData = $BaseProofRequestEventData.extend({
+ templateType: z.literal('NHS_APP').meta({
+ description: 'Template type for the template being proofed',
+ }),
+});
+
+export const $ProofRequestEventData = z
+ .discriminatedUnion('templateType', [
+ $ProofRequestNHSAppEventData,
+ $ProofRequestSMSEventData,
+ $ProofRequestEmailEventData,
+ ])
+ .meta({
+ id: 'ProofRequestEventData',
+ });
diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt
index 8d5dc59187..4824dbba99 100644
--- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt
+++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt
@@ -27,3 +27,4 @@ Terraform
toolchain
Trufflehog
Zod
+[Vv]alidators
diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts
index e6355b684f..0e1294dc56 100644
--- a/tests/test-team/global.d.ts
+++ b/tests/test-team/global.d.ts
@@ -10,6 +10,7 @@ declare global {
EVENTS_SNS_TOPIC_ARN: string;
LETTER_VARIANTS_TABLE_NAME: string;
PLAYWRIGHT_RUN_ID: string;
+ PROOF_REQUESTS_TABLE_NAME: string;
REQUEST_PROOF_QUEUE_URL: string;
ROUTING_CONFIG_TABLE_NAME: string;
SFTP_ENVIRONMENT: string;
diff --git a/tests/test-team/helpers/db/proof-requests-storage-helper.ts b/tests/test-team/helpers/db/proof-requests-storage-helper.ts
new file mode 100644
index 0000000000..7bd06ab304
--- /dev/null
+++ b/tests/test-team/helpers/db/proof-requests-storage-helper.ts
@@ -0,0 +1,94 @@
+import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
+import {
+ BatchWriteCommand,
+ DynamoDBDocumentClient,
+} from '@aws-sdk/lib-dynamodb';
+import type { DigitalProofRequest } from '../types';
+import { chunk } from 'helpers/chunk';
+
+type ProofRequestKey = { id: string; owner: string };
+
+export class ProofRequestsStorageHelper {
+ private readonly dynamo: DynamoDBDocumentClient = DynamoDBDocumentClient.from(
+ new DynamoDBClient({ region: 'eu-west-2' })
+ );
+
+ private seedData: DigitalProofRequest[] = [];
+
+ private adHocKeys: ProofRequestKey[] = [];
+
+ /**
+ * Seed a load of proof requests into the database
+ */
+ async seed(data: DigitalProofRequest[]) {
+ this.seedData.push(...data);
+
+ const chunks = chunk(data);
+
+ await Promise.all(
+ chunks.map(async (batch) => {
+ await this.dynamo.send(
+ new BatchWriteCommand({
+ RequestItems: {
+ [process.env.PROOF_REQUESTS_TABLE_NAME]: batch.map(
+ (proofRequest) => ({
+ PutRequest: {
+ Item: proofRequest,
+ },
+ })
+ ),
+ },
+ })
+ );
+ })
+ );
+ }
+
+ /**
+ * Delete proof requests seeded by calls to seed
+ */
+ public async deleteSeeded() {
+ await this.delete(
+ this.seedData.map(({ id, owner }) => ({
+ id,
+ owner,
+ }))
+ );
+ this.seedData = [];
+ }
+
+ private async delete(keys: ProofRequestKey[]) {
+ const dbChunks = chunk(keys);
+
+ await Promise.all(
+ dbChunks.map((batch) =>
+ this.dynamo.send(
+ new BatchWriteCommand({
+ RequestItems: {
+ [process.env.PROOF_REQUESTS_TABLE_NAME]: batch.map((key) => ({
+ DeleteRequest: {
+ Key: key,
+ },
+ })),
+ },
+ })
+ )
+ )
+ );
+ }
+
+ /**
+ * Stores references to proof requests created in tests (not via seeding)
+ */
+ public addAdHocKey(key: ProofRequestKey) {
+ this.adHocKeys.push(key);
+ }
+
+ /**
+ * Delete proof requests referenced by calls to addAdHocKey from database
+ */
+ async deleteAdHoc() {
+ await this.delete(this.adHocKeys);
+ this.adHocKeys = [];
+ }
+}
diff --git a/tests/test-team/helpers/types.ts b/tests/test-team/helpers/types.ts
index 2fdf5ebfd6..1043cd3877 100644
--- a/tests/test-team/helpers/types.ts
+++ b/tests/test-team/helpers/types.ts
@@ -4,6 +4,7 @@ import type {
RoutingConfig,
Language,
LetterType,
+ TemplateType,
} from 'nhs-notify-web-template-management-types';
export const templateTypeDisplayMappings: Record = {
@@ -133,3 +134,19 @@ export type FactoryRoutingConfigWithModifiers = FactoryRoutingConfig & {
templateId?: string
) => FactoryRoutingConfigWithModifiers;
};
+
+type DigitalTemplateType = Extract;
+
+export type DigitalProofRequest = {
+ id: string;
+ owner: string;
+ createdAt: string;
+ personalisation: Record;
+ contactDetails?: {
+ sms?: string;
+ email?: string;
+ };
+ templateId: string;
+ templateType: DigitalTemplateType;
+ testPatientNhsNumber: string;
+};
diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts
new file mode 100644
index 0000000000..fda2ac701c
--- /dev/null
+++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts
@@ -0,0 +1,78 @@
+import { randomUUID } from 'node:crypto';
+import {
+ templateManagementEventSubscriber as test,
+ expect,
+} from '../fixtures/template-management-event-subscriber';
+import { testUsers, type TestUser } from '../helpers/auth/cognito-auth-helper';
+import { getTestContext } from 'helpers/context/context';
+import { eventWithId } from '../helpers/events/matchers';
+import { ProofRequestsStorageHelper } from 'helpers/db/proof-requests-storage-helper';
+
+test.describe('ProofRequestedEvent', () => {
+ const context = getTestContext();
+ const proofRequestsStorageHelper = new ProofRequestsStorageHelper();
+
+ let user: TestUser;
+
+ test.beforeAll(async () => {
+ user = await context.auth.getTestUser(testUsers.User1.userId);
+ });
+
+ test.afterAll(async () => {
+ await proofRequestsStorageHelper.deleteSeeded();
+ });
+
+ test('Expect a ProofRequestedEventv1 to be published when a proof request is created', async ({
+ eventSubscriber,
+ }) => {
+ const start = new Date();
+
+ const proofRequestId = randomUUID();
+
+ // TODO: CCM-7941 - use API rather than directly into DB.
+ await proofRequestsStorageHelper.seed([
+ {
+ id: proofRequestId,
+ owner: `CLIENT#${user.clientId}`,
+ createdAt: new Date().toISOString(),
+ personalisation: {
+ gpSurgery: 'Test GP Surgery',
+ },
+ contactDetails: {
+ sms: '07999999999',
+ },
+ templateId: randomUUID(),
+ templateType: 'SMS',
+ testPatientNhsNumber: '9999999999',
+ },
+ ]);
+
+ await expect(async () => {
+ const events = await eventSubscriber.receive({
+ since: start,
+ match: eventWithId(proofRequestId),
+ });
+
+ expect(events).toHaveLength(1);
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ record: expect.objectContaining({
+ type: 'uk.nhs.notify.template-management.ProofRequested.v1',
+ data: expect.objectContaining({
+ id: proofRequestId,
+ testPatientNhsNumber: '9999999999',
+ templateType: 'SMS',
+ personalisation: {
+ gpSurgery: 'Test GP Surgery',
+ },
+ contactDetails: {
+ sms: '07999999999',
+ },
+ }),
+ }),
+ })
+ );
+ }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] });
+ });
+});
diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts
index 60f1c694f8..ff4b4998f0 100644
--- a/utils/backend-config/src/backend-config.ts
+++ b/utils/backend-config/src/backend-config.ts
@@ -22,6 +22,7 @@ export type BackendConfig = {
testEmailBucketPrefix: string;
userPoolId: string;
userPoolClientId: string;
+ proofRequestsTableName: string;
};
export const BackendConfigHelper = {
@@ -32,6 +33,7 @@ export const BackendConfigHelper = {
clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '',
environment: process.env.ENVIRONMENT ?? '',
eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '',
+ proofRequestsTableName: process.env.PROOF_REQUESTS_TABLE_NAME ?? '',
requestProofQueueUrl: process.env.REQUEST_PROOF_QUEUE_URL ?? '',
letterVariantsTableName: process.env.LETTER_VARIANTS_TABLE_NAME ?? '',
routingConfigTableName: process.env.ROUTING_CONFIG_TABLE_NAME ?? '',
@@ -75,6 +77,7 @@ export const BackendConfigHelper = {
process.env.SFTP_POLL_LAMBDA_NAME = config.sftpPollLambdaName;
process.env.TEST_EMAIL_BUCKET_NAME = config.testEmailBucketName;
process.env.TEST_EMAIL_BUCKET_PREFIX = config.testEmailBucketPrefix;
+ process.env.PROOF_REQUESTS_TABLE_NAME = config.proofRequestsTableName;
},
fromTerraformOutputsFile(filepath: string): BackendConfig {
@@ -88,6 +91,8 @@ export const BackendConfigHelper = {
outputsFileContent.client_ssm_path_prefix?.value ?? '',
environment: deployment.environment ?? '',
eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '',
+ proofRequestsTableName:
+ outputsFileContent.proof_requests_table_name?.value ?? '',
letterVariantsTableName:
outputsFileContent.letter_variants_table_name?.value ?? '',
requestProofQueueUrl: