diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md
index 964704f0..d15e7b28 100644
--- a/infrastructure/terraform/components/dl/README.md
+++ b/infrastructure/terraform/components/dl/README.md
@@ -31,6 +31,7 @@ No requirements.
| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no |
| [pdm\_mock\_access\_token](#input\_pdm\_mock\_access\_token) | Mock access token for PDM API authentication (used in local/dev environments) | `string` | `"mock-pdm-token"` | no |
| [pdm\_use\_non\_mock\_token](#input\_pdm\_use\_non\_mock\_token) | Whether to use the shared APIM access token from SSM (/component/environment/apim/access\_token) instead of the mock token | `bool` | `false` | no |
+| [pii\_data\_retention\_policy\_days](#input\_pii\_data\_retention\_policy\_days) | The number of days for data retention policy for PII | `number` | `534` | no |
| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
| [queue\_batch\_size](#input\_queue\_batch\_size) | maximum number of queue items to process | `number` | `10` | no |
| [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `1` | no |
@@ -53,11 +54,13 @@ No requirements.
| [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [print\_analyser](#module\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
+| [report\_event\_transformer](#module\_report\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
+| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
diff --git a/infrastructure/terraform/components/dl/athena_workgroup_reporting.tf b/infrastructure/terraform/components/dl/athena_workgroup_reporting.tf
new file mode 100644
index 00000000..6529f19f
--- /dev/null
+++ b/infrastructure/terraform/components/dl/athena_workgroup_reporting.tf
@@ -0,0 +1,19 @@
+resource "aws_athena_workgroup" "reporting" {
+ name = local.csi
+ description = "Athena Workgroup for ${var.environment}"
+ force_destroy = true
+
+ configuration {
+ enforce_workgroup_configuration = true
+
+ result_configuration {
+ expected_bucket_owner = var.aws_account_id
+ output_location = "s3://${module.s3bucket_reporting.bucket}/athena-output/"
+
+ encryption_configuration {
+ encryption_option = "SSE_KMS"
+ kms_key_arn = module.kms.key_arn
+ }
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/dl/aws_iam_policy_document_eventbridge_firehose_assume_role.tf b/infrastructure/terraform/components/dl/aws_iam_policy_document_eventbridge_firehose_assume_role.tf
new file mode 100644
index 00000000..296905a7
--- /dev/null
+++ b/infrastructure/terraform/components/dl/aws_iam_policy_document_eventbridge_firehose_assume_role.tf
@@ -0,0 +1,40 @@
+# IAM role for EventBridge to write to Kinesis Firehose
+data "aws_iam_policy_document" "eventbridge_firehose_assume_role" {
+ statement {
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["events.amazonaws.com"]
+ }
+
+ actions = ["sts:AssumeRole"]
+ }
+}
+
+resource "aws_iam_role" "eventbridge_firehose" {
+ name = "${local.csi}-eventbridge-firehose"
+ description = "Role for EventBridge to write to Kinesis Firehose"
+ assume_role_policy = data.aws_iam_policy_document.eventbridge_firehose_assume_role.json
+}
+
+data "aws_iam_policy_document" "eventbridge_firehose_policy" {
+ statement {
+ effect = "Allow"
+
+ actions = [
+ "firehose:PutRecord",
+ "firehose:PutRecordBatch"
+ ]
+
+ resources = [
+ aws_kinesis_firehose_delivery_stream.to_s3_reporting.arn
+ ]
+ }
+}
+
+resource "aws_iam_role_policy" "eventbridge_firehose" {
+ name = "${local.csi}-eventbridge-firehose"
+ role = aws_iam_role.eventbridge_firehose.id
+ policy = data.aws_iam_policy_document.eventbridge_firehose_policy.json
+}
diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_all_events.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_all_events.tf
new file mode 100644
index 00000000..f27e48de
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_all_events.tf
@@ -0,0 +1,20 @@
+resource "aws_cloudwatch_event_rule" "all_events" {
+ name = "${local.csi}-all-events"
+ description = "Event rule to match all Digital Letters events"
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+
+ event_pattern = jsonencode({
+ "detail" : {
+ "type" : [{
+ "prefix" : "uk.nhs.notify.digital.letters."
+ }]
+ }
+ })
+}
+
+resource "aws_cloudwatch_event_target" "reporting_firehose" {
+ rule = aws_cloudwatch_event_rule.all_events.name
+ arn = aws_kinesis_firehose_delivery_stream.to_s3_reporting.arn
+ role_arn = aws_iam_role.eventbridge_firehose.arn
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+}
diff --git a/infrastructure/terraform/components/dl/cloudwatch_log_group_kinesis_logs.tf b/infrastructure/terraform/components/dl/cloudwatch_log_group_kinesis_logs.tf
new file mode 100644
index 00000000..4133622f
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_log_group_kinesis_logs.tf
@@ -0,0 +1,9 @@
+resource "aws_cloudwatch_log_group" "kinesis_logs" {
+ name = "/aws/kinesisfirehose/${local.csi}-to-s3-reporting"
+ retention_in_days = var.log_retention_in_days
+}
+
+resource "aws_cloudwatch_log_stream" "reporting_kinesis_logs" {
+ name = "${local.csi}reportingKinesisLogs"
+ log_group_name = aws_cloudwatch_log_group.kinesis_logs.name
+}
diff --git a/infrastructure/terraform/components/dl/glue_catalog_database_reporting.tf b/infrastructure/terraform/components/dl/glue_catalog_database_reporting.tf
new file mode 100644
index 00000000..34b7c8b0
--- /dev/null
+++ b/infrastructure/terraform/components/dl/glue_catalog_database_reporting.tf
@@ -0,0 +1,4 @@
+resource "aws_glue_catalog_database" "reporting" {
+ name = "${local.csi}-reporting"
+ description = "Reporting database for ${var.environment}"
+}
diff --git a/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf
new file mode 100644
index 00000000..f03c9586
--- /dev/null
+++ b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf
@@ -0,0 +1,80 @@
+resource "aws_glue_catalog_table" "event_record" {
+ name = "event_record"
+ description = "Event records for ${var.environment}"
+ database_name = aws_glue_catalog_database.reporting.name
+
+ table_type = "EXTERNAL_TABLE"
+
+ storage_descriptor {
+ location = "s3://${module.s3bucket_reporting.bucket}/${local.firehose_output_path_prefix}/reporting/parquet/event_record"
+
+ input_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"
+ output_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat"
+
+ ser_de_info {
+ serialization_library = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"
+ }
+
+ # additional columns must be added at the end of the list
+ columns {
+ name = "messagereference"
+ type = "string"
+ }
+ columns {
+ name = "pagecount"
+ type = "int"
+ }
+ columns {
+ name = "supplierid"
+ type = "string"
+ }
+ columns {
+ name = "time"
+ type = "string"
+ }
+ columns {
+ name = "type"
+ type = "string"
+ }
+ }
+
+ partition_keys {
+ name = "senderid"
+ type = "string"
+ }
+
+ partition_keys {
+ name = "__year"
+ type = "int"
+ }
+ partition_keys {
+ name = "__month"
+ type = "int"
+ }
+ partition_keys {
+ name = "__day"
+ type = "int"
+ }
+
+ parameters = {
+ EXTERNAL = "TRUE"
+ "parquet.compression" = "SNAPPY"
+ compressionType = "none"
+ classification = "parquet"
+ }
+}
+
+resource "aws_glue_partition_index" "event_record" {
+ database_name = aws_glue_catalog_database.reporting.name
+ table_name = aws_glue_catalog_table.event_record.name
+
+ partition_index {
+ index_name = "data"
+ keys = ["senderid", "__year", "__month", "__day"]
+ }
+
+ timeouts {
+ create = "60m"
+ delete = "60m"
+ }
+}
diff --git a/infrastructure/terraform/components/dl/kinesis_firehose_delivery_stream_to_s3_reporting.tf b/infrastructure/terraform/components/dl/kinesis_firehose_delivery_stream_to_s3_reporting.tf
new file mode 100644
index 00000000..86efee7f
--- /dev/null
+++ b/infrastructure/terraform/components/dl/kinesis_firehose_delivery_stream_to_s3_reporting.tf
@@ -0,0 +1,228 @@
+resource "aws_kinesis_firehose_delivery_stream" "to_s3_reporting" {
+ name = "${local.csi}-to-s3-reporting"
+
+ destination = "extended_s3"
+
+ extended_s3_configuration {
+ role_arn = aws_iam_role.firehose_role.arn
+ bucket_arn = module.s3bucket_reporting.arn
+
+ prefix = "${local.firehose_output_path_prefix}/reporting/parquet/${aws_glue_catalog_table.event_record.name}/senderid=!{partitionKeyFromLambda:senderId}/__year=!{partitionKeyFromLambda:year}/__month=!{partitionKeyFromLambda:month}/__day=!{partitionKeyFromLambda:day}/"
+ error_output_prefix = "${local.firehose_output_path_prefix}/errors/!{timestamp:yyyy}-!{timestamp:MM}-!{timestamp:dd}-!{timestamp:HH}/!{firehose:error-output-type}/"
+
+ buffering_size = 128
+ buffering_interval = 300
+
+ dynamic_partitioning_configuration {
+ enabled = true
+ }
+
+ processing_configuration {
+ enabled = "true"
+
+ processors {
+ type = "Lambda"
+
+ parameters {
+ parameter_name = "LambdaArn"
+ parameter_value = "${module.report_event_transformer.function_arn}:$LATEST"
+ }
+ parameters {
+ parameter_name = "RoleArn"
+ parameter_value = aws_iam_role.firehose_role.arn
+ }
+ parameters {
+ parameter_name = "BufferSizeInMBs"
+ parameter_value = 1
+ }
+ parameters {
+ parameter_name = "BufferIntervalInSeconds"
+ parameter_value = 301
+ }
+ }
+ }
+
+ data_format_conversion_configuration {
+ input_format_configuration {
+ deserializer {
+ open_x_json_ser_de {}
+ }
+ }
+
+ output_format_configuration {
+ serializer {
+ parquet_ser_de {}
+ }
+ }
+
+ schema_configuration {
+ database_name = aws_glue_catalog_database.reporting.name
+ role_arn = aws_iam_role.firehose_role.arn
+ table_name = aws_glue_catalog_table.event_record.name
+ }
+ }
+
+ cloudwatch_logging_options {
+ enabled = true
+ log_group_name = aws_cloudwatch_log_group.kinesis_logs.name
+ log_stream_name = aws_cloudwatch_log_stream.reporting_kinesis_logs.name
+ }
+ }
+
+}
+
+resource "aws_iam_role" "firehose_role" {
+ name = "${local.csi}-firehose"
+ description = "Firehose Role"
+ assume_role_policy = data.aws_iam_policy_document.firehose_assume_role.json
+}
+
+data "aws_iam_policy_document" "firehose_assume_role" {
+ statement {
+ sid = "FirehoseAssumeRole"
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = [
+ "firehose.amazonaws.com"
+ ]
+ }
+
+ actions = [
+ "sts:AssumeRole"
+ ]
+ }
+}
+
+resource "aws_iam_policy" "firehose_policy" {
+ name = "${local.csi}-firehose"
+ description = "Firehose Policy"
+ path = "/"
+ policy = data.aws_iam_policy_document.firehose_policy.json
+}
+
+resource "aws_iam_role_policy_attachment" "firehose" {
+ role = aws_iam_role.firehose_role.name
+ policy_arn = aws_iam_policy.firehose_policy.arn
+}
+
+data "aws_iam_policy_document" "firehose_policy" {
+ version = "2012-10-17"
+
+ statement {
+ actions = [
+ "logs:PutLogEvents",
+ ]
+
+ resources = [
+ aws_cloudwatch_log_group.kinesis_logs.arn,
+ aws_cloudwatch_log_stream.reporting_kinesis_logs.arn
+ ]
+
+ effect = "Allow"
+ }
+
+ statement {
+ actions = [
+ "lambda:InvokeFunction",
+ "lambda:GetFunctionConfiguration",
+ ]
+
+ resources = [
+ "${module.report_event_transformer.function_arn}:$LATEST",
+ ]
+ }
+
+ statement {
+ sid = "AllowSSE"
+ effect = "Allow"
+ actions = [
+ "kms:DescribeKey",
+ "kms:Encrypt",
+ "kms:Decrypt",
+ "kms:GenerateDataKey*",
+ "kms:ReEncrypt*",
+ ]
+
+ resources = [
+ module.kms.key_arn
+ ]
+ }
+
+ statement {
+ sid = "DestinationS3Access"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetBucketLocation",
+ "s3:GetObject",
+ "s3:ListBucket",
+ "s3:ListBucketMultipartUploads",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ module.s3bucket_reporting.arn,
+ "${module.s3bucket_reporting.arn}/${local.firehose_output_path_prefix}/*"
+ ]
+ }
+
+ statement {
+ sid = "EncryptTargetData"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey"
+ ]
+ resources = [
+ module.kms.key_arn
+ ]
+
+ condition {
+ test = "StringLike"
+ variable = "kms:EncryptionContext:aws:s3:arn"
+ values = [
+ "${module.s3bucket_reporting.arn}"
+ ]
+ }
+
+ condition {
+ test = "StringEquals"
+ variable = "kms:ViaService"
+ values = [
+ "s3.${var.region}.amazonaws.com"
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowListDataStream"
+ effect = "Allow"
+ actions = [
+ "kinesis:ListStreams"
+ ]
+
+ resources = [
+ "*"
+ ]
+ }
+
+ statement {
+ sid = "AllowGlueTableAccess"
+ effect = "Allow"
+ actions = [
+ "glue:GetTable",
+ "glue:GetTableVersion",
+ "glue:GetTableVersions"
+ ]
+
+ resources = [
+ aws_glue_catalog_table.event_record.arn,
+ aws_glue_catalog_database.reporting.arn,
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog"
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf
index da187ad9..c5bbee0b 100644
--- a/infrastructure/terraform/components/dl/locals.tf
+++ b/infrastructure/terraform/components/dl/locals.tf
@@ -12,4 +12,9 @@ locals {
root_domain_id = local.acct.route53_zone_ids["digital-letters"]
ttl_shard_count = 3
deploy_pdm_mock = var.enable_pdm_mock
+ firehose_output_path_prefix = "kinesis-firehose-output"
+ pii_retention_config = {
+ current_days = var.pii_data_retention_policy_days,
+ non_current_days = 14
+ }
}
diff --git a/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf b/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf
new file mode 100644
index 00000000..21121525
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf
@@ -0,0 +1,32 @@
+module "report_event_transformer" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "report-event-transformer"
+ description = "A function for transforming all digital letter events"
+
+ aws_account_id = var.aws_account_id
+ component = local.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = module.kms.key_arn
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "report-event-transformer/dist"
+ function_include_common = true
+ handler_function_name = "handler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 60
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.log_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+}
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf
new file mode 100644
index 00000000..739f7c99
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf
@@ -0,0 +1,75 @@
+module "s3bucket_reporting" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+
+ name = "reporting"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = local.component
+
+ kms_key_arn = module.kms.key_arn
+
+ policy_documents = [data.aws_iam_policy_document.s3bucket_reporting.json]
+
+ force_destroy = var.force_destroy
+
+ lifecycle_rules = [
+ {
+ prefix = ""
+ enabled = true
+
+ expiration = {
+ days = local.pii_retention_config.current_days
+ }
+
+ noncurrent_version_expiration = {
+ noncurrent_days = local.pii_retention_config.non_current_days
+ }
+ }
+ ]
+}
+
+data "aws_iam_policy_document" "s3bucket_reporting" {
+ statement {
+ sid = "AllowManagedAccountsToList"
+ effect = "Allow"
+
+ actions = [
+ "s3:ListBucket",
+ ]
+
+ resources = [
+ module.s3bucket_reporting.arn,
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowManagedAccountsToGet"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ "${module.s3bucket_reporting.arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf
index be79f015..fb9b496a 100644
--- a/infrastructure/terraform/components/dl/variables.tf
+++ b/infrastructure/terraform/components/dl/variables.tf
@@ -89,7 +89,7 @@ variable "parent_acct_environment" {
variable "mesh_poll_schedule" {
type = string
description = "Schedule to poll MESH for messages"
- default = "rate(5 minutes)" # Every 5 minutes
+ default = "rate(5 minutes)" # Every 5 minutes
}
variable "enable_mock_mesh" {
@@ -140,7 +140,6 @@ variable "apim_base_url" {
default = "https://int.api.service.nhs.uk"
}
-
variable "core_notify_url" {
type = string
description = "The URL used to send requests to Notify"
@@ -169,6 +168,11 @@ variable "force_destroy" {
type = bool
description = "Flag to force deletion of S3 buckets"
default = false
+
+ validation {
+ condition = !(var.force_destroy && var.environment == "prod")
+ error_message = "force_destroy must not be set to true when environment is 'prod'."
+ }
}
variable "enable_pdm_mock" {
@@ -176,3 +180,9 @@ variable "enable_pdm_mock" {
description = "Flag indicating whether to deploy PDM mock API (should be false in production environments)"
default = true
}
+
+variable "pii_data_retention_policy_days" {
+ type = number
+ description = "The number of days for data retention policy for PII"
+ default = 534
+}
diff --git a/lambdas/report-event-transformer/jest.config.ts b/lambdas/report-event-transformer/jest.config.ts
new file mode 100644
index 00000000..c02601ae
--- /dev/null
+++ b/lambdas/report-event-transformer/jest.config.ts
@@ -0,0 +1,5 @@
+import { baseJestConfig } from '../../jest.config.base';
+
+const config = baseJestConfig;
+
+export default config;
diff --git a/lambdas/report-event-transformer/package.json b/lambdas/report-event-transformer/package.json
new file mode 100644
index 00000000..a6d9f388
--- /dev/null
+++ b/lambdas/report-event-transformer/package.json
@@ -0,0 +1,25 @@
+{
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "utils": "^0.0.1",
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ },
+ "name": "nhs-notify-digital-letters-report-event-transformer",
+ "private": true,
+ "scripts": {
+ "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "0.0.1"
+}
diff --git a/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts b/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts
new file mode 100644
index 00000000..10942086
--- /dev/null
+++ b/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts
@@ -0,0 +1,112 @@
+import { mock } from 'jest-mock-extended';
+import { createHandler } from 'apis/firehose-handler';
+import { Logger } from 'utils';
+import { digitalLettersEvent, firehoseEvent } from '__tests__/test-data';
+
+const logger = mock();
+
+const handler = createHandler({
+ logger,
+});
+
+describe('Firehose Handler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('status', () => {
+ it('should process valid digital letters event and return report event', async () => {
+ const response = await handler(firehoseEvent([digitalLettersEvent]));
+
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Received Firehose Event of 1 record(s)',
+ );
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '1 of 1 records processed successfully',
+ );
+
+ expect(response.records).toHaveLength(1);
+ expect(response.records[0]).toMatchObject({
+ recordId: '1',
+ result: 'Ok',
+ metadata: {
+ partitionKeys: {
+ year: '2023',
+ month: '6',
+ day: '20',
+ senderId: 'sender1',
+ },
+ },
+ });
+
+ // Verify the data is base64 encoded JSON
+ const decodedData = JSON.parse(
+ Buffer.from(response.records[0].data!, 'base64').toString('utf8'),
+ );
+ expect(decodedData).toEqual({
+ messageReference: digitalLettersEvent.data.messageReference,
+ senderId: digitalLettersEvent.data.senderId,
+ pageCount: digitalLettersEvent.data.pageCount,
+ supplierId: digitalLettersEvent.data.supplierId,
+ time: digitalLettersEvent.time,
+ type: digitalLettersEvent.type,
+ });
+ });
+ });
+
+ describe('errors', () => {
+ it('should return ProcessingFailed for records with invalid JSON', async () => {
+ const event = firehoseEvent([digitalLettersEvent]);
+ event.records[0].data = Buffer.from('not-json').toString('base64');
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: expect.any(SyntaxError),
+ description: 'Error parsing firehose record',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result.records).toHaveLength(1);
+ expect(result.records[0]).toMatchObject({
+ recordId: '1',
+ result: 'ProcessingFailed',
+ });
+ });
+
+ it('should return ProcessingFailed for records with invalid event schema', async () => {
+ const invalidEvent = {
+ ...digitalLettersEvent,
+ type: 123, // Invalid: should be string
+ };
+ const event = firehoseEvent([invalidEvent as any]);
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: expect.objectContaining({
+ issues: expect.arrayContaining([
+ expect.objectContaining({
+ path: ['type'],
+ }),
+ ]),
+ }),
+ description: 'Error parsing firehose item',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result.records).toHaveLength(1);
+ expect(result.records[0]).toMatchObject({
+ recordId: '1',
+ result: 'ProcessingFailed',
+ });
+ });
+ });
+});
diff --git a/lambdas/report-event-transformer/src/__tests__/container.test.ts b/lambdas/report-event-transformer/src/__tests__/container.test.ts
new file mode 100644
index 00000000..1859a92a
--- /dev/null
+++ b/lambdas/report-event-transformer/src/__tests__/container.test.ts
@@ -0,0 +1,12 @@
+import { createContainer } from 'container';
+
+jest.mock('utils', () => ({
+ logger: {},
+}));
+
+describe('container', () => {
+ it('should create container', () => {
+ const container = createContainer();
+ expect(container).toBeDefined();
+ });
+});
diff --git a/lambdas/report-event-transformer/src/__tests__/index.test.ts b/lambdas/report-event-transformer/src/__tests__/index.test.ts
new file mode 100644
index 00000000..c1ecd810
--- /dev/null
+++ b/lambdas/report-event-transformer/src/__tests__/index.test.ts
@@ -0,0 +1,15 @@
+import { handler } from 'index';
+
+jest.mock('apis/firehose-handler', () => ({
+ createHandler: jest.fn(() => jest.fn()),
+}));
+
+jest.mock('container', () => ({
+ createContainer: jest.fn(() => ({})),
+}));
+
+describe('index', () => {
+ it('should export handler', () => {
+ expect(handler).toBeDefined();
+ });
+});
diff --git a/lambdas/report-event-transformer/src/__tests__/test-data.ts b/lambdas/report-event-transformer/src/__tests__/test-data.ts
new file mode 100644
index 00000000..e8043885
--- /dev/null
+++ b/lambdas/report-event-transformer/src/__tests__/test-data.ts
@@ -0,0 +1,52 @@
+import { FirehoseTransformationEvent } from 'aws-lambda';
+import { DigitalLettersEvent } from 'types/events';
+
+const baseEvent = {
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ specversion: '1.0',
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm',
+ subject:
+ 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
+ type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1',
+ time: '2023-06-20T12:00:00Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ severitynumber: 2,
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ datacontenttype: 'application/json',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json',
+ severitytext: 'INFO',
+ data: {
+ resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09',
+ messageReference: 'ref1',
+ senderId: 'sender1',
+ },
+};
+
+export const digitalLettersEvent = {
+ ...baseEvent,
+ type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-submitted-data.schema.json',
+} as DigitalLettersEvent;
+
+export const firehoseEvent = (
+ events: DigitalLettersEvent[],
+): FirehoseTransformationEvent => ({
+ invocationId: 'test-invocation-id',
+ deliveryStreamArn:
+ 'arn:aws:firehose:eu-west-2:123456789012:deliverystream/test',
+ region: 'eu-west-2',
+ records: events.map((event, i) => ({
+ recordId: String(i + 1),
+ approximateArrivalTimestamp: Date.now(),
+ data: Buffer.from(
+ JSON.stringify({
+ version: '0',
+ id: 'ab07d406-0797-e919-ff9b-3ad9c5498114',
+ detail: event,
+ }),
+ ).toString('base64'),
+ })),
+});
diff --git a/lambdas/report-event-transformer/src/apis/firehose-handler.ts b/lambdas/report-event-transformer/src/apis/firehose-handler.ts
new file mode 100644
index 00000000..171003a2
--- /dev/null
+++ b/lambdas/report-event-transformer/src/apis/firehose-handler.ts
@@ -0,0 +1,113 @@
+import type {
+ FirehoseTransformationEvent,
+ FirehoseTransformationEventRecord,
+ FirehoseTransformationResult,
+ FirehoseTransformationResultRecord,
+} from 'aws-lambda';
+import {
+ $DigitalLettersEvent,
+ DigitalLettersEvent,
+ FlatDigitalLettersEvent,
+ ReportEvent,
+} from 'types/events';
+import { Logger } from 'utils';
+
+export interface HandlerDependencies {
+ logger: Logger;
+}
+
+type ValidatedRecord = {
+ recordId: string;
+ event: DigitalLettersEvent;
+};
+
+function validateRecord(
+ { data, recordId }: FirehoseTransformationEventRecord,
+ logger: Logger,
+): ValidatedRecord | null {
+ try {
+ const eventBody = JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
+ const eventDetail = eventBody.detail;
+
+ const {
+ data: item,
+ error: parseError,
+ success: parseSuccess,
+ } = $DigitalLettersEvent.safeParse(eventDetail);
+
+ if (!parseSuccess) {
+ logger.warn({
+ err: parseError,
+ description: 'Error parsing firehose item',
+ });
+
+ return null;
+ }
+
+ return { recordId, event: item };
+ } catch (error) {
+ logger.warn({
+ err: error,
+ description: 'Error parsing firehose record',
+ });
+
+ return null;
+ }
+}
+
+function generateReportEvent(validatedRecord: ValidatedRecord): ReportEvent {
+ const { messageReference, pageCount, senderId, supplierId } =
+ validatedRecord.event.data;
+ const { time, type } = validatedRecord.event;
+ const eventTime = new Date(time);
+
+ const flattenedEvent: FlatDigitalLettersEvent = {
+ messageReference,
+ senderId,
+ pageCount,
+ supplierId,
+ time,
+ type,
+ };
+
+ return {
+ recordId: validatedRecord.recordId,
+ data: Buffer.from(JSON.stringify(flattenedEvent)).toString('base64'),
+ result: 'Ok',
+ metadata: {
+ partitionKeys: {
+ year: eventTime.getUTCFullYear().toString(),
+ month: (eventTime.getUTCMonth() + 1).toString(),
+ day: eventTime.getUTCDate().toString(),
+ senderId,
+ },
+ },
+ };
+}
+
+export const createHandler = ({ logger }: HandlerDependencies) =>
+ async function handler(
+ firehoseTransformationEvent: FirehoseTransformationEvent,
+ ): Promise {
+ const receivedItemCount = firehoseTransformationEvent.records.length;
+ const failedEvents: FirehoseTransformationResultRecord[] = [];
+ const validEvents: ReportEvent[] = [];
+
+ logger.info(`Received Firehose Event of ${receivedItemCount} record(s)`);
+
+ for (const record of firehoseTransformationEvent.records) {
+ const validated = validateRecord(record, logger);
+ if (validated) {
+ validEvents.push(generateReportEvent(validated));
+ } else {
+ failedEvents.push({ ...record, result: 'ProcessingFailed' });
+ }
+ }
+
+ const processedItemCount = receivedItemCount - failedEvents.length;
+ logger.info(
+ `${processedItemCount} of ${receivedItemCount} records processed successfully`,
+ );
+
+ return { records: [...validEvents, ...failedEvents] };
+ };
diff --git a/lambdas/report-event-transformer/src/container.ts b/lambdas/report-event-transformer/src/container.ts
new file mode 100644
index 00000000..431346a9
--- /dev/null
+++ b/lambdas/report-event-transformer/src/container.ts
@@ -0,0 +1,8 @@
+import { HandlerDependencies } from 'apis/firehose-handler';
+import { logger } from 'utils';
+
+export const createContainer = (): HandlerDependencies => {
+ return { logger };
+};
+
+export default createContainer;
diff --git a/lambdas/report-event-transformer/src/index.ts b/lambdas/report-event-transformer/src/index.ts
new file mode 100644
index 00000000..0794289c
--- /dev/null
+++ b/lambdas/report-event-transformer/src/index.ts
@@ -0,0 +1,6 @@
+import { createHandler } from 'apis/firehose-handler';
+import { createContainer } from 'container';
+
+export const handler = createHandler(createContainer());
+
+export default handler;
diff --git a/lambdas/report-event-transformer/src/types/events.ts b/lambdas/report-event-transformer/src/types/events.ts
new file mode 100644
index 00000000..c018f0f2
--- /dev/null
+++ b/lambdas/report-event-transformer/src/types/events.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+
+export const $DigitalLettersEvent = z.object({
+ data: z.object({
+ messageReference: z.string(),
+ senderId: z.string(),
+ pageCount: z.number().optional(),
+ supplierId: z.string().optional(),
+ }),
+ time: z.string(),
+ type: z.string(),
+});
+
+export type DigitalLettersEvent = z.infer;
+
+export type FlatDigitalLettersEvent = {
+ messageReference: string;
+ senderId: string;
+ pageCount?: number;
+ supplierId?: string;
+ time: string;
+ type: string;
+};
+
+// Custom type similar to FirehoseTransformationResultRecord from aws-lambda,
+// but with strict metadata typing for dynamic partitioning keys
+export type ReportEvent = {
+ recordId: string;
+ data: string;
+ result: 'Ok' | 'Dropped' | 'ProcessingFailed';
+ metadata: {
+ partitionKeys: {
+ year: string;
+ month: string;
+ day: string;
+ senderId: string;
+ };
+ };
+};
diff --git a/lambdas/report-event-transformer/tsconfig.json b/lambdas/report-event-transformer/tsconfig.json
new file mode 100644
index 00000000..f7bcaa1f
--- /dev/null
+++ b/lambdas/report-event-transformer/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./src/",
+ "isolatedModules": true
+ },
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 421176ad..7b16ce83 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"lambdas/core-notifier-lambda",
"lambdas/print-status-handler",
"lambdas/print-analyser",
+ "lambdas/report-event-transformer",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
@@ -2723,6 +2724,329 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "lambdas/report-event-transformer": {
+ "name": "nhs-notify-digital-letters-report-event-transformer",
+ "version": "0.0.1",
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "utils": "^0.0.1",
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/@jest/core": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
+ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/reporters": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-changed-files": "^29.7.0",
+ "jest-config": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-resolve-dependencies": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "lambdas/report-event-transformer/node_modules/@types/jest": {
+ "version": "29.5.14",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
+ "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/expect-utils": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
+ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "import-local": "^3.0.2",
+ "jest-cli": "^29.7.0"
+ },
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-cli": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
+ "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "create-jest": "^29.7.0",
+ "exit": "^0.1.2",
+ "import-local": "^3.0.2",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "yargs": "^17.3.1"
+ },
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-message-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
+ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.12.13",
+ "@jest/types": "^29.6.3",
+ "@types/stack-utils": "^2.0.0",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-mock-extended": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz",
+ "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ts-essentials": "^10.0.0"
+ },
+ "peerDependencies": {
+ "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0",
+ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-regex-util": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
+ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-snapshot": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
+ "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@babel/generator": "^7.7.2",
+ "@babel/plugin-syntax-jsx": "^7.7.2",
+ "@babel/plugin-syntax-typescript": "^7.7.2",
+ "@babel/types": "^7.3.3",
+ "@jest/expect-utils": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0",
+ "chalk": "^4.0.0",
+ "expect": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "natural-compare": "^1.4.0",
+ "pretty-format": "^29.7.0",
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/jest-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
+ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "graceful-fs": "^4.2.9",
+ "picomatch": "^2.2.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/report-event-transformer/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
"lambdas/ttl-create-lambda": {
"name": "nhs-notify-digital-letters-ttl-create-lambda",
"version": "0.0.1",
@@ -11389,6 +11713,97 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/create-jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
+ "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "prompts": "^2.0.1"
+ },
+ "bin": {
+ "create-jest": "bin/create-jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/create-jest/node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/create-jest/node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/create-jest/node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/create-jest/node_modules/jest-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
+ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "graceful-fs": "^4.2.9",
+ "picomatch": "^2.2.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/create-jest/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -18671,6 +19086,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -19170,6 +19595,10 @@
"resolved": "lambdas/print-status-handler",
"link": true
},
+ "node_modules/nhs-notify-digital-letters-report-event-transformer": {
+ "resolved": "lambdas/report-event-transformer",
+ "link": true
+ },
"node_modules/nhs-notify-digital-letters-ttl-create-lambda": {
"resolved": "lambdas/ttl-create-lambda",
"link": true
@@ -19940,6 +20369,20 @@
"node": ">= 0.6.0"
}
},
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -20669,6 +21112,13 @@
"node": ">=0.3.1"
}
},
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
diff --git a/package.json b/package.json
index 14477577..a7a6e0e1 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"lambdas/core-notifier-lambda",
"lambdas/print-status-handler",
"lambdas/print-analyser",
+ "lambdas/report-event-transformer",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
diff --git a/project.code-workspace b/project.code-workspace
index b7407a2a..16295b33 100644
--- a/project.code-workspace
+++ b/project.code-workspace
@@ -80,15 +80,22 @@
"terminal.integrated.scrollback": 10000,
"shellcheck.run": "onSave",
"jest.virtualFolders": [
+ { "name": "core-notifier-lambda", "rootPath": "lambdas/core-notifier-lambda" },
{ "name": "key-generation", "rootPath": "lambdas/key-generation" },
- { "name": "refresh-apim-access-token", "rootPath": "lambdas/refresh-apim-access-token" },
+ { "name": "pdm-mock-lambda", "rootPath": "lambdas/pdm-mock-lambda" },
+ { "name": "pdm-poll-lambda", "rootPath": "lambdas/pdm-poll-lambda" },
+ { "name": "pdm-uploader-lambda", "rootPath": "lambdas/pdm-uploader-lambda" },
+ { "name": "print-status-handler", "rootPath": "lambdas/print-status-handler" },
{ "name": "python-schema-generator", "rootPath": "src/python-schema-generator" },
+ { "name": "refresh-apim-access-token", "rootPath": "lambdas/refresh-apim-access-token" },
+ { "name": "report-event-transformer", "rootPath": "lambdas/report-event-transformer" },
+ { "name": "sender-management", "rootPath": "utils/sender-management" },
{ "name": "ttl-create-lambda", "rootPath": "lambdas/ttl-create-lambda/" },
{ "name": "ttl-handle-expiry-lambda", "rootPath": "lambdas/ttl-handle-expiry-lambda" },
{ "name": "ttl-poll-lambda", "rootPath": "lambdas/ttl-poll-lambda" },
- { "name": "sender-management", "rootPath": "utils/sender-management" },
{ "name": "utils", "rootPath": "utils/utils" },
],
+ "testing.defaultGutterClickAction": "runWithCoverage",
},
"extensions": {
"recommendations": [