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: