diff --git a/docs/generate-includes.sh b/docs/generate-includes.sh
index 75b217a97..2df5e62c4 100755
--- a/docs/generate-includes.sh
+++ b/docs/generate-includes.sh
@@ -24,6 +24,7 @@ cp ./lambdas/upsert-letter/README.md ./docs/_includes/components/generated/readm
cp ./lambdas/update-letter-queue/README.md ./docs/_includes/components/generated/readmes/lambda-update-letter-queue.md
cp ./lambdas/letter-updates-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-letter-updates-transformer.md
cp ./lambdas/mi-updates-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-mi-updates-transformer.md
+cp ./lambdas/eventpub-parquet-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-eventpub-parquet-transformer.md
cp ./lambdas/supplier-config-ingress/README.md ./docs/_includes/components/generated/readmes/lambda-supplier-config-ingress.md
# Function documentation (internal packages)
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 09a55d787..65dc77b54 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -115,6 +115,7 @@ export default defineConfig([
eslintImportResolverTypescript.createTypeScriptImportResolver({
project: [
'lambdas/*/tsconfig.json',
+ 'lambdas/*/*/tsconfig.json',
'tests/tsconfig.json',
'internal/*/tsconfig.json',
],
diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index b4d8adfb4..54bdd4171 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -54,6 +54,8 @@ 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 |
| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
| [region](#input\_region) | The AWS Region | `string` | n/a | yes |
+| [reports\_data\_retention\_non\_current\_days](#input\_reports\_data\_retention\_non\_current\_days) | Number of days to retain noncurrent reporting bucket object versions | `number` | `14` | no |
+| [reports\_data\_retention\_policy\_days](#input\_reports\_data\_retention\_policy\_days) | Number of days to retain reporting bucket data | `number` | `90` | no |
| [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no |
| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no |
| [supplier\_mock\_schedule\_expression](#input\_supplier\_mock\_schedule\_expression) | Schedule expression for supplier mock scheduler | `string` | `"rate(1 minute)"` | no |
@@ -71,6 +73,7 @@ No requirements.
| [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a |
| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a |
| [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/5.0.6/terraform-eventpub.zip | n/a |
+| [eventpub\_parquet\_transformer](#module\_eventpub\_parquet\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a |
| [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
@@ -83,8 +86,12 @@ No requirements.
| [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [mi\_updates\_transformer](#module\_mi\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
+| [populate\_sameday\_breaches](#module\_populate\_sameday\_breaches) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
+| [populate\_standard\_breaches](#module\_populate\_standard\_breaches) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
+| [populate\_supplier\_config\_cache](#module\_populate\_supplier\_config\_cache) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
+| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip | n/a |
| [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
| [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a |
| [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip | n/a |
diff --git a/infrastructure/terraform/components/api/athena_workgroup_reporting.tf b/infrastructure/terraform/components/api/athena_workgroup_reporting.tf
new file mode 100644
index 000000000..08d7fa3ea
--- /dev/null
+++ b/infrastructure/terraform/components/api/athena_workgroup_reporting.tf
@@ -0,0 +1,19 @@
+resource "aws_athena_workgroup" "reporting" {
+ name = local.csi
+ description = "Supplier API 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/api/cloudwatch_log_group_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/cloudwatch_log_group_eventpub_parquet_firehose.tf
new file mode 100644
index 000000000..95a9fc627
--- /dev/null
+++ b/infrastructure/terraform/components/api/cloudwatch_log_group_eventpub_parquet_firehose.tf
@@ -0,0 +1,7 @@
+resource "aws_cloudwatch_log_group" "eventpub_parquet_firehose" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "/aws/firehose/${local.csi}-eventpub-parquet"
+ kms_key_id = module.kms.key_arn
+ retention_in_days = var.log_retention_in_days
+}
diff --git a/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf
new file mode 100644
index 000000000..e634886b4
--- /dev/null
+++ b/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf
@@ -0,0 +1,6 @@
+resource "aws_cloudwatch_log_stream" "eventpub_parquet_firehose" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "extended_s3"
+ log_group_name = aws_cloudwatch_log_group.eventpub_parquet_firehose[0].name
+}
diff --git a/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf b/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf
new file mode 100644
index 000000000..233e27a0c
--- /dev/null
+++ b/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf
@@ -0,0 +1,60 @@
+resource "aws_glue_catalog_table" "eventpub_parquet" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ name = "${replace(local.csi, "-", "_")}_eventpub"
+ database_name = aws_glue_catalog_database.supplier.name
+ table_type = "EXTERNAL_TABLE"
+
+ parameters = {
+ EXTERNAL = "TRUE"
+ classification = "parquet"
+ "parquet.compress" = "SNAPPY"
+
+ # Athena derives partitions from these properties, so none are registered in Glue
+ "projection.enabled" = "true"
+ "projection.__year.type" = "integer"
+ "projection.__year.range" = "2025,2050"
+ "projection.__year.digits" = "4"
+ "projection.__month.type" = "integer"
+ "projection.__month.range" = "1,12"
+ "projection.__month.digits" = "2"
+ "projection.__day.type" = "integer"
+ "projection.__day.range" = "1,31"
+ "projection.__day.digits" = "2"
+ "storage.location.template" = "s3://${module.s3bucket_reporting.bucket}/parquet/eventpub/__year=$${__year}/__month=$${__month}/__day=$${__day}/"
+ }
+
+ storage_descriptor {
+ location = "s3://${module.s3bucket_reporting.bucket}/parquet/eventpub/"
+ input_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"
+ output_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat"
+
+ ser_de_info {
+ name = "ParquetHiveSerDe"
+ serialization_library = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"
+ }
+
+ dynamic "columns" {
+ for_each = local.eventpub_parquet_table_columns
+
+ content {
+ name = columns.value.name
+ type = columns.value.type
+ }
+ }
+ }
+
+ partition_keys {
+ name = "__year"
+ type = "string"
+ }
+
+ partition_keys {
+ name = "__month"
+ type = "string"
+ }
+
+ partition_keys {
+ name = "__day"
+ type = "string"
+ }
+}
diff --git a/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf b/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf
new file mode 100644
index 000000000..d17c10f33
--- /dev/null
+++ b/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf
@@ -0,0 +1,108 @@
+locals {
+ supplier_config_cache_table_columns = {
+ supplier = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "channelType", type = "string" },
+ { name = "dailyCapacity", type = "bigint" },
+ { name = "id", type = "string" },
+ { name = "name", type = "string" },
+ { name = "status", type = "string" },
+ ]
+ volume-group = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "description", type = "string" },
+ { name = "endDate", type = "string" },
+ { name = "id", type = "string" },
+ { name = "name", type = "string" },
+ { name = "startDate", type = "string" },
+ { name = "status", type = "string" },
+ ]
+ supplier-pack = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "approval", type = "string" },
+ { name = "id", type = "string" },
+ { name = "packSpecificationId", type = "string" },
+ { name = "status", type = "string" },
+ { name = "supplierId", type = "string" },
+ ]
+ letter-variant = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "campaignIds", type = "array" },
+ { name = "clientId", type = "string" },
+ { name = "constraints", type = "struct,colourCoveragePercentage:struct,deliveryDays:struct,sheets:struct,sides:struct>" },
+ { name = "description", type = "string" },
+ { name = "id", type = "string" },
+ { name = "name", type = "string" },
+ { name = "packSpecificationIds", type = "array" },
+ { name = "priority", type = "bigint" },
+ { name = "status", type = "string" },
+ { name = "supplierId", type = "string" },
+ { name = "type", type = "string" },
+ { name = "volumeGroupId", type = "string" },
+ ]
+ pack-specification = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "assembly", type = "struct,insertIds:array,paper:struct,printColour:string>" },
+ { name = "billingId", type = "string" },
+ { name = "constraints", type = "struct,colourCoveragePercentage:struct,deliveryDays:struct,sheets:struct,sides:struct>" },
+ { name = "createdAt", type = "string" },
+ { name = "description", type = "string" },
+ { name = "id", type = "string" },
+ { name = "name", type = "string" },
+ { name = "postage", type = "struct" },
+ { name = "status", type = "string" },
+ { name = "updatedAt", type = "string" },
+ { name = "version", type = "bigint" },
+ ]
+ supplier-allocation = [
+ { name = "pk", type = "string" },
+ { name = "sk", type = "string" },
+ { name = "allocationPercentage", type = "double" },
+ { name = "id", type = "string" },
+ { name = "status", type = "string" },
+ { name = "supplier", type = "string" },
+ { name = "volumeGroup", type = "string" },
+ ]
+ }
+}
+
+resource "aws_glue_catalog_table" "supplier_config_cache" {
+ for_each = local.supplier_config_cache_table_columns
+ name = "${replace(local.csi, "-", "_")}_${replace(each.key, "-", "_")}"
+ database_name = aws_glue_catalog_database.supplier.name
+ table_type = "EXTERNAL_TABLE"
+
+ parameters = {
+ EXTERNAL = "TRUE"
+ classification = "json"
+ }
+
+ storage_descriptor {
+ location = "s3://${module.s3bucket_reporting.bucket}/supplier_config_cache/${each.key}/"
+ input_format = "org.apache.hadoop.mapred.TextInputFormat"
+ output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"
+
+ ser_de_info {
+ name = "OpenXJsonSerDe"
+ serialization_library = "org.openx.data.jsonserde.JsonSerDe"
+
+ parameters = {
+ "case.insensitive" = "false"
+ }
+ }
+
+ dynamic "columns" {
+ for_each = each.value
+
+ content {
+ name = columns.value.name
+ type = columns.value.type
+ }
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf
new file mode 100644
index 000000000..c39074016
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf
@@ -0,0 +1,18 @@
+resource "aws_iam_role" "eventpub_parquet_firehose" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "${local.csi}-eventpub-parquet-firehose"
+ assume_role_policy = data.aws_iam_policy_document.eventpub_parquet_firehose_assume_role.json
+}
+
+data "aws_iam_policy_document" "eventpub_parquet_firehose_assume_role" {
+ statement {
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["firehose.amazonaws.com"]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf
new file mode 100644
index 000000000..50363d68b
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf
@@ -0,0 +1,18 @@
+resource "aws_iam_role" "eventpub_parquet_sns" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "${local.csi}-eventpub-parquet-sns"
+ assume_role_policy = data.aws_iam_policy_document.eventpub_parquet_sns_assume_role.json
+}
+
+data "aws_iam_policy_document" "eventpub_parquet_sns_assume_role" {
+ statement {
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["sns.amazonaws.com"]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf
new file mode 100644
index 000000000..b56042514
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf
@@ -0,0 +1,18 @@
+resource "aws_iam_role" "letter_sla_breaches" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ name = "${local.csi}-letter-sla-breaches-sfn"
+ description = "Execution role for the letter SLA breaches state machine"
+ assume_role_policy = data.aws_iam_policy_document.letter_sla_breaches_trust_policy.json
+}
+
+data "aws_iam_policy_document" "letter_sla_breaches_trust_policy" {
+ statement {
+ effect = "Allow"
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["states.amazonaws.com"]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf
new file mode 100644
index 000000000..ae8273301
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf
@@ -0,0 +1,79 @@
+resource "aws_iam_role_policy" "eventpub_parquet_firehose" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "${local.csi}-eventpub-parquet-firehose"
+ role = aws_iam_role.eventpub_parquet_firehose[0].id
+ policy = data.aws_iam_policy_document.eventpub_parquet_firehose[0].json
+}
+
+data "aws_iam_policy_document" "eventpub_parquet_firehose" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ statement {
+ sid = "AllowDestinationS3Access"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetBucketLocation",
+ "s3:GetObject",
+ "s3:ListBucket",
+ "s3:ListBucketMultipartUploads",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ module.s3bucket_reporting.arn,
+ "${module.s3bucket_reporting.arn}/parquet/*",
+ ]
+ }
+
+ statement {
+ sid = "AllowKms"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:DescribeKey",
+ "kms:GenerateDataKey*",
+ ]
+
+ resources = [module.kms.key_arn]
+ }
+
+ statement {
+ sid = "AllowInvokeTransformer"
+ effect = "Allow"
+
+ actions = [
+ "lambda:InvokeFunction",
+ "lambda:GetFunctionConfiguration",
+ ]
+
+ resources = ["${module.eventpub_parquet_transformer.function_arn}:$LATEST"]
+ }
+
+ statement {
+ sid = "AllowGlueSchemaRead"
+ effect = "Allow"
+
+ actions = [
+ "glue:GetTable",
+ "glue:GetTableVersion",
+ "glue:GetTableVersions",
+ ]
+
+ resources = [
+ aws_glue_catalog_table.eventpub_parquet[0].arn,
+ aws_glue_catalog_database.supplier.arn,
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog",
+ ]
+ }
+
+ statement {
+ sid = "AllowLogging"
+ effect = "Allow"
+ actions = ["logs:PutLogEvents"]
+ resources = ["${aws_cloudwatch_log_group.eventpub_parquet_firehose[0].arn}:*"]
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf
new file mode 100644
index 000000000..b7e41a764
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf
@@ -0,0 +1,30 @@
+resource "aws_iam_role_policy" "eventpub_parquet_sns" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "${local.csi}-eventpub-parquet-sns"
+ role = aws_iam_role.eventpub_parquet_sns[0].id
+ policy = data.aws_iam_policy_document.eventpub_parquet_sns[0].json
+}
+
+data "aws_iam_policy_document" "eventpub_parquet_sns" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ statement {
+ sid = "AllowFirehoseDelivery"
+ effect = "Allow"
+
+ actions = [
+ "firehose:PutRecord",
+ "firehose:PutRecordBatch",
+ ]
+
+ resources = [aws_kinesis_firehose_delivery_stream.eventpub_parquet[0].arn]
+ }
+
+ statement {
+ sid = "AllowKms"
+ effect = "Allow"
+ actions = ["kms:GenerateDataKey", "kms:Decrypt"]
+ resources = [module.kms.key_arn]
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
new file mode 100644
index 000000000..25d483c6c
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
@@ -0,0 +1,28 @@
+resource "aws_iam_role_policy" "letter_sla_breaches" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ name = "${local.csi}-letter-sla-breaches-sfn"
+ role = aws_iam_role.letter_sla_breaches[0].id
+ policy = data.aws_iam_policy_document.letter_sla_breaches_policy[0].json
+}
+
+data "aws_iam_policy_document" "letter_sla_breaches_policy" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ statement {
+ sid = "AllowListOwnExecutions"
+ effect = "Allow"
+ actions = ["states:ListExecutions"]
+ resources = [local.letter_sla_breaches_state_machine_arn]
+ }
+
+ statement {
+ sid = "AllowInvokeLambdas"
+ effect = "Allow"
+ actions = ["lambda:InvokeFunction"]
+ resources = [
+ module.populate_supplier_config_cache.function_arn,
+ module.populate_sameday_breaches[0].function_arn,
+ module.populate_standard_breaches[0].function_arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf b/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf
new file mode 100644
index 000000000..4dcdec6e2
--- /dev/null
+++ b/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf
@@ -0,0 +1,66 @@
+resource "aws_kinesis_firehose_delivery_stream" "eventpub_parquet" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ name = "${local.csi}-eventpub-parquet"
+ destination = "extended_s3"
+
+ server_side_encryption {
+ enabled = true
+ key_type = "CUSTOMER_MANAGED_CMK"
+ key_arn = module.kms.key_arn
+ }
+
+ extended_s3_configuration {
+ role_arn = aws_iam_role.eventpub_parquet_firehose[0].arn
+ bucket_arn = module.s3bucket_reporting.arn
+
+ prefix = "parquet/eventpub/__year=!{timestamp:yyyy}/__month=!{timestamp:MM}/__day=!{timestamp:dd}/"
+ error_output_prefix = "parquet/errors/eventpub/!{firehose:error-output-type}/!{timestamp:yyyy}-!{timestamp:MM}-!{timestamp:dd}/"
+
+ buffering_size = 128
+ buffering_interval = 300
+
+ processing_configuration {
+ enabled = true
+
+ processors {
+ type = "Lambda"
+
+ parameters {
+ parameter_name = "LambdaArn"
+ parameter_value = "${module.eventpub_parquet_transformer.function_arn}:$LATEST"
+ }
+ parameters {
+ parameter_name = "RoleArn"
+ parameter_value = aws_iam_role.eventpub_parquet_firehose[0].arn
+ }
+ }
+ }
+
+ 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.supplier.name
+ role_arn = aws_iam_role.eventpub_parquet_firehose[0].arn
+ table_name = aws_glue_catalog_table.eventpub_parquet[0].name
+ }
+ }
+
+ cloudwatch_logging_options {
+ enabled = true
+ log_group_name = aws_cloudwatch_log_group.eventpub_parquet_firehose[0].name
+ log_stream_name = aws_cloudwatch_log_stream.eventpub_parquet_firehose[0].name
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 6b4d90305..1b7db3ec6 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -1,4 +1,34 @@
locals {
+ eventpub_parquet_enabled = local.event_cache_bucket_name != null
+
+ # Column names must be lowercase; OpenXJsonSerDe matches JSON keys case-insensitively.
+ eventpub_parquet_table_columns = [
+ { name = "specversion", type = "string" },
+ { name = "id", type = "string" },
+ { name = "type", type = "string" },
+ { name = "plane", type = "string" },
+ { name = "dataschema", type = "string" },
+ { name = "dataschemaversion", type = "string" },
+ { name = "source", type = "string" },
+ { name = "subject", type = "string" },
+ { name = "time", type = "string" },
+ { name = "recordedtime", type = "string" },
+ { name = "datacontenttype", type = "string" },
+ { name = "traceparent", type = "string" },
+ { name = "tracestate", type = "string" },
+ { name = "partitionkey", type = "string" },
+ { name = "sampledrate", type = "bigint" },
+ { name = "sequence", type = "string" },
+ { name = "severitytext", type = "string" },
+ { name = "data_json", type = "string" },
+ { name = "domainid", type = "string" },
+ { name = "supplierid", type = "string" },
+ { name = "groupid", type = "string" },
+ { name = "campaignid", type = "string" },
+ { name = "clientid", type = "string" },
+ { name = "status", type = "string" },
+ ]
+
aws_lambda_functions_dir_path = "../../../../lambdas"
root_domain_name = "${var.environment}.${local.acct.route53_zone_names["supplier-api"]}" # e.g. [main|dev|abxy0].supplier-api.[dev|nonprod|prod].nhsnotify.national.nhs.uk
root_domain_id = local.acct.route53_zone_ids["supplier-api"]
@@ -52,4 +82,7 @@ locals {
var.region,
var.csoc_destination_account
)
+
+ letter_sla_breaches_state_machine_name = "${local.csi}-letter-sla-breaches"
+ letter_sla_breaches_state_machine_arn = "arn:aws:states:${var.region}:${var.aws_account_id}:stateMachine:${local.letter_sla_breaches_state_machine_name}"
}
diff --git a/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf b/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf
new file mode 100644
index 000000000..4f0aa4be2
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf
@@ -0,0 +1,35 @@
+module "eventpub_parquet_transformer" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip"
+
+ function_name = "eventpub-parquet-transformer"
+ description = "Flattens EventPub events for parquet delivery by Firehose"
+
+ aws_account_id = var.aws_account_id
+ component = var.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 = "eventpub-parquet-transformer/dist"
+ function_include_common = true
+ handler_function_name = "handler"
+ runtime = "nodejs22.x"
+ memory = 256
+ timeout = 60
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ odin_log_destination_arn = local.odin_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = local.common_lambda_env_vars
+}
diff --git a/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf
new file mode 100644
index 000000000..da6140233
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf
@@ -0,0 +1,126 @@
+moved {
+ from = module.populate_sameday_breaches
+ to = module.populate_sameday_breaches[0]
+}
+
+module "populate_sameday_breaches" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip"
+
+ function_name = "populate_sameday_breaches"
+ description = "Populates same-day breaches for the letter SLA breaches state machine"
+
+ aws_account_id = var.aws_account_id
+ component = var.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
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.populate_sameday_breaches_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "letter-sla-breaches/populate-sameday-breaches/dist"
+ function_include_common = true
+ handler_function_name = "populateSamedayBreachesHandler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 29
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ odin_log_destination_arn = local.odin_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = {
+ ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name
+ ATHENA_DATABASE = aws_glue_catalog_database.supplier.name
+ EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name
+ }
+}
+
+data "aws_iam_policy_document" "populate_sameday_breaches_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+ statement {
+ sid = "AllowAthenaQueryExecution"
+ effect = "Allow"
+
+ actions = [
+ "athena:GetQueryExecution",
+ "athena:GetQueryResults",
+ "athena:StartQueryExecution",
+ ]
+
+ resources = [aws_athena_workgroup.reporting.arn]
+ }
+
+ statement {
+ sid = "AllowGlueCatalogRead"
+ effect = "Allow"
+
+ actions = [
+ "glue:GetDatabase",
+ "glue:GetPartitions",
+ "glue:GetTable",
+ ]
+
+ resources = [
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog",
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:database/${aws_glue_catalog_database.supplier.name}",
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:table/${aws_glue_catalog_database.supplier.name}/*",
+ ]
+ }
+
+ statement {
+ sid = "AllowReadReportingData"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetBucketLocation",
+ "s3:ListBucket",
+ ]
+
+ resources = [module.s3bucket_reporting.arn]
+ }
+
+ statement {
+ sid = "AllowReadEventData"
+ effect = "Allow"
+ actions = ["s3:GetObject"]
+ resources = ["${module.s3bucket_reporting.arn}/parquet/*"]
+ }
+
+ statement {
+ sid = "AllowAthenaOutput"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetObject",
+ "s3:PutObject",
+ ]
+
+ resources = ["${module.s3bucket_reporting.arn}/athena-output/*"]
+ }
+}
diff --git a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
new file mode 100644
index 000000000..083f5ddfe
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
@@ -0,0 +1,121 @@
+module "populate_standard_breaches" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip"
+
+ function_name = "populate_standard_breaches"
+ description = "Populates standard breaches for the letter SLA breaches state machine"
+
+ aws_account_id = var.aws_account_id
+ component = var.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
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.populate_standard_breaches_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "letter-sla-breaches/populate-standard-breaches/dist"
+ function_include_common = true
+ handler_function_name = "populateStandardBreachesHandler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 29
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ odin_log_destination_arn = local.odin_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = {
+ ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name
+ ATHENA_DATABASE = aws_glue_catalog_database.supplier.name
+ EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name
+ }
+}
+
+data "aws_iam_policy_document" "populate_standard_breaches_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+ statement {
+ sid = "AllowAthenaQueryExecution"
+ effect = "Allow"
+
+ actions = [
+ "athena:GetQueryExecution",
+ "athena:GetQueryResults",
+ "athena:StartQueryExecution",
+ ]
+
+ resources = [aws_athena_workgroup.reporting.arn]
+ }
+
+ statement {
+ sid = "AllowGlueCatalogRead"
+ effect = "Allow"
+
+ actions = [
+ "glue:GetDatabase",
+ "glue:GetPartitions",
+ "glue:GetTable",
+ ]
+
+ resources = [
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog",
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:database/${aws_glue_catalog_database.supplier.name}",
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:table/${aws_glue_catalog_database.supplier.name}/*",
+ ]
+ }
+
+ statement {
+ sid = "AllowReadReportingData"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetBucketLocation",
+ "s3:ListBucket",
+ ]
+
+ resources = [module.s3bucket_reporting.arn]
+ }
+
+ statement {
+ sid = "AllowReadEventData"
+ effect = "Allow"
+ actions = ["s3:GetObject"]
+ resources = ["${module.s3bucket_reporting.arn}/parquet/*"]
+ }
+
+ statement {
+ sid = "AllowAthenaOutput"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetObject",
+ "s3:PutObject",
+ ]
+
+ resources = ["${module.s3bucket_reporting.arn}/athena-output/*"]
+ }
+}
diff --git a/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf b/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf
new file mode 100644
index 000000000..86339dda2
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf
@@ -0,0 +1,72 @@
+module "populate_supplier_config_cache" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip"
+
+ function_name = "populate_supplier_config_cache"
+ description = "Populates the supplier config cache for the letter SLA breaches state machine"
+
+ aws_account_id = var.aws_account_id
+ component = var.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
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.populate_supplier_config_cache_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "letter-sla-breaches/populate-supplier-config-cache/dist"
+ function_include_common = true
+ handler_function_name = "populateSupplierConfigCacheHandler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 29
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.destination_arn
+ odin_log_destination_arn = local.odin_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = {
+ SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name
+ EVENT_CACHE_BUCKET_NAME = module.s3bucket_reporting.bucket
+ }
+}
+
+data "aws_iam_policy_document" "populate_supplier_config_cache_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowQuerySupplierConfigTable"
+ effect = "Allow"
+ actions = ["dynamodb:Query"]
+ resources = [aws_dynamodb_table.supplier-configuration.arn]
+ }
+
+ statement {
+ sid = "AllowWriteSupplierConfigCache"
+ effect = "Allow"
+ actions = ["s3:PutObject"]
+ resources = ["${module.s3bucket_reporting.arn}/supplier_config_cache/*"]
+ }
+}
diff --git a/infrastructure/terraform/components/api/module_s3bucket_reporting.tf b/infrastructure/terraform/components/api/module_s3bucket_reporting.tf
new file mode 100644
index 000000000..f3e3d0a35
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_s3bucket_reporting.tf
@@ -0,0 +1,79 @@
+module "s3bucket_reporting" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip"
+
+ name = "supapi-reporting"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = var.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 = "athena-output"
+ enabled = true
+
+ expiration = {
+ days = var.reports_data_retention_policy_days
+ }
+
+ noncurrent_version_expiration = {
+ noncurrent_days = var.reports_data_retention_non_current_days
+ }
+ },
+ ]
+
+ default_tags = {
+ NHSE-Enable-S3-Backup-Acct = "True",
+ }
+}
+
+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/api/resources/letter_sla_breaches.json b/infrastructure/terraform/components/api/resources/letter_sla_breaches.json
new file mode 100644
index 000000000..456a54ff1
--- /dev/null
+++ b/infrastructure/terraform/components/api/resources/letter_sla_breaches.json
@@ -0,0 +1,92 @@
+{
+ "Comment": "Identifies letters in breach of SLA for a given date (defaults to today)",
+ "QueryLanguage": "JSONata",
+ "StartAt": "CheckForRunningExecutions",
+ "States": {
+ "AlreadyRunning?": {
+ "Choices": [
+ {
+ "Condition": "{% $count($states.input.Executions) > 1 %}",
+ "Next": "ExitAlreadyRunning"
+ }
+ ],
+ "Default": "PopulateSupplierConfigCache",
+ "Type": "Choice"
+ },
+ "CheckForRunningExecutions": {
+ "Arguments": {
+ "StateMachineArn": "${STATE_MACHINE_ARN}",
+ "StatusFilter": "RUNNING"
+ },
+ "Assign": {
+ "date": "{% $exists($states.context.Execution.Input.date) ? $states.context.Execution.Input.date : $substring($states.context.Execution.StartTime, 0, 10) %}"
+ },
+ "Next": "AlreadyRunning?",
+ "Resource": "arn:aws:states:::aws-sdk:sfn:listExecutions",
+ "Type": "Task"
+ },
+ "ExitAlreadyRunning": {
+ "Type": "Succeed"
+ },
+ "PopulateBreachesInParallel": {
+ "Branches": [
+ {
+ "StartAt": "PopulateSamedayBreaches",
+ "States": {
+ "PopulateSamedayBreaches": {
+ "Arguments": {
+ "FunctionName": "${POPULATE_SAMEDAY_BREACHES_FUNCTION_ARN}",
+ "Payload": {
+ "date": "{% $date %}"
+ }
+ },
+ "End": true,
+ "Output": {
+ "populateSamedayBreaches": "{% $states.result.Payload %}"
+ },
+ "Resource": "arn:aws:states:::lambda:invoke",
+ "Type": "Task"
+ }
+ }
+ },
+ {
+ "StartAt": "PopulateStandardBreaches",
+ "States": {
+ "PopulateStandardBreaches": {
+ "Arguments": {
+ "FunctionName": "${POPULATE_STANDARD_BREACHES_FUNCTION_ARN}",
+ "Payload": {
+ "date": "{% $date %}"
+ }
+ },
+ "End": true,
+ "Output": {
+ "populateStandardBreaches": "{% $states.result.Payload %}"
+ },
+ "Resource": "arn:aws:states:::lambda:invoke",
+ "Type": "Task"
+ }
+ }
+ }
+ ],
+ "End": true,
+ "Output": {
+ "populateSamedayBreaches": "{% $states.result[0].populateSamedayBreaches %}",
+ "populateStandardBreaches": "{% $states.result[1].populateStandardBreaches %}",
+ "populateSupplierConfigCache": "{% $states.input.populateSupplierConfigCache %}"
+ },
+ "Type": "Parallel"
+ },
+ "PopulateSupplierConfigCache": {
+ "Arguments": {
+ "FunctionName": "${POPULATE_SUPPLIER_CONFIG_CACHE_FUNCTION_ARN}"
+ },
+ "Next": "PopulateBreachesInParallel",
+ "Output": {
+ "populateSupplierConfigCache": "{% $states.result.Payload %}"
+ },
+ "Resource": "arn:aws:states:::lambda:invoke",
+ "Type": "Task"
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
new file mode 100644
index 000000000..ccb5a8ef9
--- /dev/null
+++ b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
@@ -0,0 +1,12 @@
+resource "aws_sfn_state_machine" "letter_sla_breaches" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+ name = local.letter_sla_breaches_state_machine_name
+ role_arn = aws_iam_role.letter_sla_breaches[0].arn
+
+ definition = templatefile("${path.module}/resources/letter_sla_breaches.json", {
+ STATE_MACHINE_ARN = local.letter_sla_breaches_state_machine_arn
+ POPULATE_SUPPLIER_CONFIG_CACHE_FUNCTION_ARN = module.populate_supplier_config_cache.function_arn
+ POPULATE_SAMEDAY_BREACHES_FUNCTION_ARN = module.populate_sameday_breaches[0].function_arn
+ POPULATE_STANDARD_BREACHES_FUNCTION_ARN = module.populate_standard_breaches[0].function_arn
+ })
+}
diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf
new file mode 100644
index 000000000..da3237198
--- /dev/null
+++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf
@@ -0,0 +1,10 @@
+# Raw delivery so Firehose receives the bare event rather than the SNS envelope
+resource "aws_sns_topic_subscription" "eventpub_parquet" {
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
+ topic_arn = module.eventpub.sns_topic.arn
+ protocol = "firehose"
+ subscription_role_arn = aws_iam_role.eventpub_parquet_sns[0].arn
+ endpoint = aws_kinesis_firehose_delivery_stream.eventpub_parquet[0].arn
+ raw_message_delivery = true
+}
diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf
index f1499c3fc..2d0b63bfa 100644
--- a/infrastructure/terraform/components/api/variables.tf
+++ b/infrastructure/terraform/components/api/variables.tf
@@ -292,6 +292,18 @@ variable "csoc_destination_account" {
default = "000000000000"
}
+variable "reports_data_retention_policy_days" {
+ type = number
+ description = "Number of days to retain reporting bucket data"
+ default = 90
+}
+
+variable "reports_data_retention_non_current_days" {
+ type = number
+ description = "Number of days to retain noncurrent reporting bucket object versions"
+ default = 14
+}
+
variable "event_source_filter" {
type = list(string)
description = "SNS subscription source filter values"
diff --git a/lambdas/eventpub-parquet-transformer/README.md b/lambdas/eventpub-parquet-transformer/README.md
new file mode 100644
index 000000000..eb1a5c458
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/README.md
@@ -0,0 +1,17 @@
+
+
+# EventPub Parquet Transformer Lambda
+
+## Purpose
+
+Firehose data-transformation processor for the EventPub parquet delivery stream. Flattens each CloudEvent so Firehose can convert it to Parquet for Athena.
+
+## General Flow
+
+1. Firehose invokes the Lambda with a batch of base64 encoded CloudEvents.
+2. Each event is parsed and returned as a flat JSON record: the envelope attributes, the raw `data` payload as `data_json`, and the promoted `domainid`, `supplierid`, `groupid`, `campaignid` and `clientid` properties.
+3. Records that are not valid JSON objects are returned as `ProcessingFailed` and are written to the stream's error prefix.
+
+## Key Integration Points
+
+- **Kinesis Data Firehose**: the `eventpub-parquet` delivery stream.
diff --git a/lambdas/eventpub-parquet-transformer/jest.config.ts b/lambdas/eventpub-parquet-transformer/jest.config.ts
new file mode 100644
index 000000000..93ec73a53
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/jest.config.ts
@@ -0,0 +1,63 @@
+import type { Config } from "jest";
+
+export const baseJestConfig: Config = {
+ preset: "ts-jest",
+
+ // Automatically clear mock calls, instances, contexts and results before every test
+ clearMocks: true,
+
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: true,
+
+ // The directory where Jest should output its coverage files
+ coverageDirectory: "./.reports/unit/coverage",
+
+ // Indicates which provider should be used to instrument code for coverage
+ coverageProvider: "babel",
+
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: -10,
+ },
+ },
+
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ transform: { "^.+\\.ts$": "ts-jest" },
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
+ testPathIgnorePatterns: [".build"],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+
+ // Use this configuration option to add custom reporters to Jest
+ reporters: [
+ "default",
+ [
+ "jest-html-reporter",
+ {
+ pageTitle: "Test Report",
+ outputPath: "./.reports/unit/test-report.html",
+ includeFailureMsg: true,
+ },
+ ],
+ ],
+
+ // The test environment that will be used for testing
+ testEnvironment: "jsdom",
+};
+
+const utilsJestConfig = {
+ ...baseJestConfig,
+
+ testEnvironment: "node",
+
+ coveragePathIgnorePatterns: [
+ ...(baseJestConfig.coveragePathIgnorePatterns ?? []),
+ "zod-validators.ts",
+ ],
+};
+
+export default utilsJestConfig;
diff --git a/lambdas/eventpub-parquet-transformer/package.json b/lambdas/eventpub-parquet-transformer/package.json
new file mode 100644
index 000000000..7512fb336
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/package.json
@@ -0,0 +1,16 @@
+{
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "esbuild": "0.28.2"
+ },
+ "name": "nhs-notify-supplier-api-eventpub-parquet-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/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts b/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts
new file mode 100644
index 000000000..f08104dd1
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts
@@ -0,0 +1,130 @@
+import { FirehoseTransformationEvent } from "aws-lambda";
+import { handler } from "..";
+import transformRecord from "../transform-record";
+
+const encode = (value: unknown) =>
+ Buffer.from(
+ typeof value === "string" ? value : JSON.stringify(value),
+ "utf8",
+ ).toString("base64");
+
+const decode = (data: string) =>
+ JSON.parse(Buffer.from(data, "base64").toString("utf8"));
+
+describe("transformRecord", () => {
+ it("flattens the envelope, keeps data as JSON and promotes properties", () => {
+ const data = {
+ domainId: "d1",
+ supplierId: "s1",
+ groupId: "cl1-c1-t1",
+ status: "PRINTED",
+ };
+
+ const out = transformRecord({
+ recordId: "r1",
+ data: encode({ id: "e1", type: "t", time: "now", data }),
+ });
+
+ expect(out.result).toBe("Ok");
+ expect(out.recordId).toBe("r1");
+ expect(Buffer.from(out.data!, "base64").toString().endsWith("\n")).toBe(
+ true,
+ );
+ expect(decode(out.data!)).toEqual({
+ id: "e1",
+ type: "t",
+ time: "now",
+ data_json: JSON.stringify(data),
+ domainid: "d1",
+ supplierid: "s1",
+ groupid: "cl1-c1-t1",
+ campaignid: "c1",
+ clientid: "cl1",
+ status: "PRINTED",
+ });
+ });
+
+ it.each([
+ ["cl1-c1-t1", "cl1", "c1"],
+ ["cl1-c1", "cl1", "c1"],
+ ["cl1", "cl1", undefined],
+ ["cl1--t1", "cl1", undefined],
+ ["-c1-t1", undefined, "c1"],
+ [" - ", undefined, undefined],
+ ["", undefined, undefined],
+ [undefined, undefined, undefined],
+ [123, undefined, undefined],
+ ])(
+ "derives clientid and campaignid from groupId %p",
+ (groupId, clientid, campaignid) => {
+ const out = decode(
+ transformRecord({
+ recordId: "r",
+ data: encode({ id: "e", data: { groupId } }),
+ }).data!,
+ );
+
+ expect(out.clientid).toBe(clientid);
+ expect(out.campaignid).toBe(campaignid);
+ },
+ );
+
+ it("ignores clientId and campaignId properties on the data", () => {
+ const out = decode(
+ transformRecord({
+ recordId: "r",
+ data: encode({
+ id: "e",
+ data: { groupId: "cl1-c1-t1", clientId: "x", campaignId: "y" },
+ }),
+ }).data!,
+ );
+
+ expect(out.clientid).toBe("cl1");
+ expect(out.campaignid).toBe("c1");
+ });
+
+ it("leaves promoted properties empty when data is missing or not an object", () => {
+ const missing = decode(
+ transformRecord({ recordId: "r", data: encode({ id: "e" }) }).data!,
+ );
+ const scalar = decode(
+ transformRecord({ recordId: "r", data: encode({ id: "e", data: 1 }) })
+ .data!,
+ );
+
+ expect(missing).toEqual({ id: "e", data_json: "null" });
+ expect(scalar).toEqual({ id: "e", data_json: "1" });
+ });
+
+ it.each([["not json"], ["[1]"], ["null"]])(
+ "returns ProcessingFailed for %s",
+ (payload) => {
+ const record = { recordId: "r", data: encode(payload) };
+
+ expect(transformRecord(record)).toEqual({
+ recordId: "r",
+ result: "ProcessingFailed",
+ data: record.data,
+ });
+ },
+ );
+});
+
+describe("handler", () => {
+ it("transforms every record in the batch", async () => {
+ const event = {
+ records: [
+ { recordId: "1", data: encode({ id: "a" }) },
+ { recordId: "2", data: encode("bad") },
+ ],
+ } as unknown as FirehoseTransformationEvent;
+
+ const res = await handler(event, {} as never, () => {});
+
+ expect(res!.records.map((r) => r.result)).toEqual([
+ "Ok",
+ "ProcessingFailed",
+ ]);
+ });
+});
diff --git a/lambdas/eventpub-parquet-transformer/src/index.ts b/lambdas/eventpub-parquet-transformer/src/index.ts
new file mode 100644
index 000000000..b310292f4
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/src/index.ts
@@ -0,0 +1,7 @@
+import { FirehoseTransformationHandler } from "aws-lambda";
+import transformRecord from "./transform-record";
+
+// eslint-disable-next-line import-x/prefer-default-export
+export const handler: FirehoseTransformationHandler = async (event) => ({
+ records: event.records.map((record) => transformRecord(record)),
+});
diff --git a/lambdas/eventpub-parquet-transformer/src/transform-record.ts b/lambdas/eventpub-parquet-transformer/src/transform-record.ts
new file mode 100644
index 000000000..a9800e750
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/src/transform-record.ts
@@ -0,0 +1,99 @@
+import type {
+ FirehoseRecordTransformationStatus,
+ FirehoseTransformationResultRecord,
+} from "aws-lambda";
+
+const ENVELOPE_FIELDS = [
+ "specversion",
+ "id",
+ "type",
+ "plane",
+ "dataschema",
+ "dataschemaversion",
+ "source",
+ "subject",
+ "time",
+ "recordedtime",
+ "datacontenttype",
+ "traceparent",
+ "tracestate",
+ "partitionkey",
+ "sampledrate",
+ "sequence",
+ "severitytext",
+] as const;
+
+const PROMOTED_DATA_FIELDS = [
+ "domainId",
+ "supplierId",
+ "groupId",
+ "status",
+] as const;
+
+type Json = Record;
+
+function isObject(value: unknown): value is Json {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+// groupId is "--"; missing or blank parts stay undefined
+function parseGroupId(groupId: unknown): {
+ clientId?: string;
+ campaignId?: string;
+} {
+ if (typeof groupId !== "string") {
+ return {};
+ }
+ const [clientId, campaignId] = groupId.split("-");
+ return {
+ clientId: clientId?.trim() || undefined,
+ campaignId: campaignId?.trim() || undefined,
+ };
+}
+
+function flatten(event: Json): Json {
+ const data = isObject(event.data) ? event.data : {};
+
+ const flat: Json = {};
+ for (const field of ENVELOPE_FIELDS) {
+ flat[field] = event[field];
+ }
+ flat.data_json = JSON.stringify(event.data ?? null);
+ for (const field of PROMOTED_DATA_FIELDS) {
+ flat[field.toLowerCase()] = data[field];
+ }
+ const { campaignId, clientId } = parseGroupId(data.groupId);
+ flat.clientid = clientId;
+ flat.campaignid = campaignId;
+ return flat;
+}
+
+function result(
+ recordId: string,
+ status: FirehoseRecordTransformationStatus,
+ data: string,
+): FirehoseTransformationResultRecord {
+ return { recordId, result: status, data };
+}
+
+export default function transformRecord(record: {
+ recordId: string;
+ data: string;
+}): FirehoseTransformationResultRecord {
+ try {
+ const event: unknown = JSON.parse(
+ Buffer.from(record.data, "base64").toString("utf8"),
+ );
+ if (!isObject(event)) {
+ return result(record.recordId, "ProcessingFailed", record.data);
+ }
+ const output = `${JSON.stringify(flatten(event))}\n`;
+ return result(
+ record.recordId,
+ "Ok",
+ Buffer.from(output, "utf8").toString("base64"),
+ );
+ } catch {
+ return result(record.recordId, "ProcessingFailed", record.data);
+ }
+}
diff --git a/lambdas/eventpub-parquet-transformer/tsconfig.json b/lambdas/eventpub-parquet-transformer/tsconfig.json
new file mode 100644
index 000000000..bb8177b74
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true
+ },
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/lambdas/letter-sla-breaches/common/jest.config.ts b/lambdas/letter-sla-breaches/common/jest.config.ts
new file mode 100644
index 000000000..d38a979df
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/jest.config.ts
@@ -0,0 +1,28 @@
+export default {
+ preset: "ts-jest",
+ extensionsToTreatAsEsm: [".ts"],
+ transform: {
+ "^.+\\.ts$": [
+ "ts-jest",
+ {
+ useESM: true,
+ },
+ ],
+ },
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "./.reports/unit/coverage",
+ coverageProvider: "babel",
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: -10,
+ },
+ },
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ testPathIgnorePatterns: [".build"],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+ testEnvironment: "node",
+};
diff --git a/lambdas/letter-sla-breaches/common/package.json b/lambdas/letter-sla-breaches/common/package.json
new file mode 100644
index 000000000..3e5758ac4
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/package.json
@@ -0,0 +1,19 @@
+{
+ "dependencies": {
+ "@aws-sdk/client-athena": "^3.1149.0",
+ "@internal/helpers": "^0.1.0",
+ "pino": "^10.4.0"
+ },
+ "description": "Common utilities for letter SLA breach lambdas",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "name": "@internal/letter-sla-breaches-common",
+ "private": true,
+ "scripts": {
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "0.0.1"
+}
diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts
new file mode 100644
index 000000000..932387f95
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts
@@ -0,0 +1,76 @@
+import buildAthenaDatePredicate from "../athena-date-predicate";
+
+describe("buildAthenaDatePredicate", () => {
+ it("includes only the requested UTC date when no days are subtracted", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2026-10-14T23:59:59.999Z"), 0),
+ ).toBe("(__year = '2026' AND __month = '10' AND __day IN ('14'))");
+ });
+
+ it("groups consecutive dates in the same month into one partition predicate", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2026-10-14T12:00:00.000Z"), 2),
+ ).toBe(
+ "(__year = '2026' AND __month = '10' AND __day IN ('12', '13', '14'))",
+ );
+ });
+
+ it("creates separate predicates when the date range crosses a month", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2026-11-01T00:00:00.000Z"), 2),
+ ).toBe(
+ "(__year = '2026' AND __month = '10' AND __day IN ('30', '31'))\n" +
+ " OR (__year = '2026' AND __month = '11' AND __day IN ('01'))",
+ );
+ });
+
+ it("creates separate predicates when the date range crosses a year", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2026-01-01T00:00:00.000Z"), 1),
+ ).toBe(
+ "(__year = '2025' AND __month = '12' AND __day IN ('31'))\n" +
+ " OR (__year = '2026' AND __month = '01' AND __day IN ('01'))",
+ );
+ });
+
+ it("includes leap days in the date range", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2024-03-01T00:00:00.000Z"), 2),
+ ).toBe(
+ "(__year = '2024' AND __month = '02' AND __day IN ('28', '29'))\n" +
+ " OR (__year = '2024' AND __month = '03' AND __day IN ('01'))",
+ );
+ });
+
+ it("uses UTC calendar dates regardless of the time component", () => {
+ expect(
+ buildAthenaDatePredicate(new Date("2026-10-14T00:30:00+02:00"), 1),
+ ).toBe("(__year = '2026' AND __month = '10' AND __day IN ('12', '13'))");
+ });
+
+ it("throws when given an invalid date", () => {
+ expect(() => buildAthenaDatePredicate(new Date(Number.NaN), 0)).toThrow(
+ new RangeError("date must be valid"),
+ );
+ });
+
+ it.each([
+ -1,
+ 1.5,
+ Number.NaN,
+ Number.POSITIVE_INFINITY,
+ Number.MAX_SAFE_INTEGER + 1,
+ ])(
+ "throws when daysToSubtract is not a non-negative safe integer (%s)",
+ (daysToSubtract) => {
+ expect(() =>
+ buildAthenaDatePredicate(
+ new Date("2026-10-14T00:00:00.000Z"),
+ daysToSubtract,
+ ),
+ ).toThrow(
+ new RangeError("daysToSubtract must be a non-negative integer"),
+ );
+ },
+ );
+});
diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
new file mode 100644
index 000000000..b11ff60ae
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
@@ -0,0 +1,154 @@
+import {
+ AthenaClient,
+ GetQueryExecutionCommand,
+ GetQueryResultsCommand,
+ StartQueryExecutionCommand,
+} from "@aws-sdk/client-athena";
+import runAthenaQuery from "../athena";
+import { type Deps } from "../deps";
+
+const query = { queryString: "SELECT ?", executionParameters: ["'a'"] };
+
+function makeDeps(responses: unknown[]) {
+ const send = jest.fn();
+ for (const response of responses) {
+ send.mockResolvedValueOnce(response);
+ }
+ const deps = {
+ athenaClient: { send } as unknown as AthenaClient,
+ logger: { info: jest.fn(), debug: jest.fn() },
+ env: {
+ ATHENA_WORKGROUP: "workgroup",
+ ATHENA_DATABASE: "database",
+ EVENT_CACHE_TABLE_NAME: "event_cache",
+ },
+ } as unknown as Deps;
+ return { deps, send };
+}
+
+const columnInfo = { ColumnInfo: [{ Name: "id" }, { Name: "status" }] };
+
+describe("runAthenaQuery", () => {
+ it("starts the query, polls until complete and returns paginated rows", async () => {
+ const { deps, send } = makeDeps([
+ { QueryExecutionId: "qid" },
+ { QueryExecution: { Status: { State: "QUEUED" } } },
+ { QueryExecution: { Status: { State: "RUNNING" } } },
+ { QueryExecution: { Status: { State: "SUCCEEDED" } } },
+ {
+ ResultSet: {
+ ResultSetMetadata: columnInfo,
+ Rows: [
+ { Data: [{ VarCharValue: "id" }, { VarCharValue: "status" }] },
+ { Data: [{ VarCharValue: "1" }, { VarCharValue: "PENDING" }] },
+ ],
+ },
+ NextToken: "next",
+ },
+ {
+ ResultSet: {
+ ResultSetMetadata: columnInfo,
+ Rows: [{ Data: [{ VarCharValue: "2" }, {}] }],
+ },
+ },
+ ]);
+
+ const rows = await runAthenaQuery(deps, query, { pollIntervalMs: 0 });
+
+ expect(rows).toEqual([
+ { id: "1", status: "PENDING" },
+ { id: "2", status: undefined },
+ ]);
+ const commands = send.mock.calls.map(([command]) => command);
+ expect(commands[0]).toBeInstanceOf(StartQueryExecutionCommand);
+ expect(commands[0].input).toEqual({
+ QueryString: "SELECT ?",
+ ExecutionParameters: ["'a'"],
+ WorkGroup: "workgroup",
+ QueryExecutionContext: { Database: "database" },
+ });
+ expect(commands[1]).toBeInstanceOf(GetQueryExecutionCommand);
+ expect(commands[4]).toBeInstanceOf(GetQueryResultsCommand);
+ expect(commands[5].input).toEqual({
+ QueryExecutionId: "qid",
+ NextToken: "next",
+ });
+ });
+
+ it("handles empty result sets and missing metadata", async () => {
+ const { deps } = makeDeps([
+ { QueryExecutionId: "qid" },
+ { QueryExecution: { Status: { State: "SUCCEEDED" } } },
+ {
+ ResultSet: { ResultSetMetadata: { ColumnInfo: [{}] }, Rows: [{}, {}] },
+ },
+ ]);
+
+ await expect(runAthenaQuery(deps, query)).resolves.toEqual([
+ { "": undefined },
+ ]);
+ });
+
+ it("handles a response with no result set", async () => {
+ const { deps } = makeDeps([
+ { QueryExecutionId: "qid" },
+ { QueryExecution: { Status: { State: "SUCCEEDED" } } },
+ {},
+ ]);
+
+ await expect(runAthenaQuery(deps, query)).resolves.toEqual([]);
+ });
+
+ it("uses the default poll interval when none is given", async () => {
+ jest.useFakeTimers();
+ const { deps } = makeDeps([
+ { QueryExecutionId: "qid" },
+ {},
+ { QueryExecution: { Status: { State: "SUCCEEDED" } } },
+ {},
+ ]);
+
+ const promise = runAthenaQuery(deps, query);
+ await jest.advanceTimersByTimeAsync(1000);
+
+ await expect(promise).resolves.toEqual([]);
+ jest.useRealTimers();
+ });
+
+ it("throws when no query execution id is returned", async () => {
+ const { deps } = makeDeps([{}]);
+
+ await expect(runAthenaQuery(deps, query)).rejects.toThrow(
+ "Athena did not return a query execution id",
+ );
+ });
+
+ it.each(["FAILED", "CANCELLED"])(
+ "throws when the query is %s",
+ async (state) => {
+ const { deps } = makeDeps([
+ { QueryExecutionId: "qid" },
+ {
+ QueryExecution: {
+ Status: { State: state, StateChangeReason: "bad query" },
+ },
+ },
+ ]);
+
+ await expect(runAthenaQuery(deps, query)).rejects.toThrow(
+ `Athena query qid ${state}: bad query`,
+ );
+ },
+ );
+
+ it("throws with a default reason when none is given", async () => {
+ const { deps } = makeDeps([
+ { QueryExecutionId: "qid" },
+ { QueryExecution: { Status: { State: "FAILED" } } },
+ ]);
+
+ await expect(runAthenaQuery(deps, query)).rejects.toThrow(
+ "Athena query qid FAILED: no reason given",
+ );
+ });
+});
diff --git a/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts b/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts
new file mode 100644
index 000000000..17b74ed1d
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts
@@ -0,0 +1,45 @@
+export default function buildAthenaDatePredicate(
+ date: Date,
+ daysToSubtract: number,
+): string {
+ if (Number.isNaN(date.getTime())) {
+ throw new RangeError("date must be valid");
+ }
+
+ if (!Number.isSafeInteger(daysToSubtract) || daysToSubtract < 0) {
+ throw new RangeError("daysToSubtract must be a non-negative integer");
+ }
+
+ const endDate = new Date(date);
+ endDate.setUTCHours(0, 0, 0, 0);
+
+ const currentDate = new Date(endDate);
+ currentDate.setUTCDate(currentDate.getUTCDate() - daysToSubtract);
+
+ const groups: { year: string; month: string; days: string[] }[] = [];
+
+ while (currentDate <= endDate) {
+ const year = String(currentDate.getUTCFullYear()).padStart(4, "0");
+ const month = String(currentDate.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(currentDate.getUTCDate()).padStart(2, "0");
+ const lastGroup = groups.at(-1);
+
+ if (lastGroup?.year === year && lastGroup.month === month) {
+ lastGroup.days.push(day);
+ } else {
+ groups.push({ year, month, days: [day] });
+ }
+
+ currentDate.setUTCDate(currentDate.getUTCDate() + 1);
+ }
+
+ return groups
+ .map(
+ // eslint-disable-next-line sort-destructure-keys/sort-destructure-keys
+ ({ year, month, days }) =>
+ `(__year = '${year}' AND __month = '${month}' AND __day IN (${days
+ .map((day) => `'${day}'`)
+ .join(", ")}))`,
+ )
+ .join("\n OR ");
+}
diff --git a/lambdas/letter-sla-breaches/common/src/athena.ts b/lambdas/letter-sla-breaches/common/src/athena.ts
new file mode 100644
index 000000000..59cf6c338
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/athena.ts
@@ -0,0 +1,132 @@
+import {
+ GetQueryExecutionCommand,
+ GetQueryResultsCommand,
+ GetQueryResultsCommandOutput,
+ QueryExecutionState,
+ StartQueryExecutionCommand,
+} from "@aws-sdk/client-athena";
+import { Deps } from "./deps";
+
+export type AthenaQuery = {
+ queryString: string;
+ executionParameters?: string[];
+};
+
+export type AthenaQueryOptions = {
+ pollIntervalMs?: number;
+};
+
+export type AthenaRow = Record;
+
+const DEFAULT_POLL_INTERVAL_MS = 1000;
+
+const sleep = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+async function startQuery(deps: Deps, query: AthenaQuery): Promise {
+ const { QueryExecutionId } = await deps.athenaClient.send(
+ new StartQueryExecutionCommand({
+ QueryString: query.queryString,
+ ExecutionParameters: query.executionParameters,
+ WorkGroup: deps.env.ATHENA_WORKGROUP,
+ QueryExecutionContext: { Database: deps.env.ATHENA_DATABASE },
+ }),
+ );
+
+ if (!QueryExecutionId) {
+ throw new Error("Athena did not return a query execution id");
+ }
+
+ return QueryExecutionId;
+}
+
+async function waitForQuery(
+ deps: Deps,
+ queryExecutionId: string,
+ pollIntervalMs: number,
+): Promise {
+ for (;;) {
+ const { QueryExecution } = await deps.athenaClient.send(
+ new GetQueryExecutionCommand({ QueryExecutionId: queryExecutionId }),
+ );
+ const state = QueryExecution?.Status?.State;
+
+ switch (state) {
+ case QueryExecutionState.SUCCEEDED: {
+ return;
+ }
+ case QueryExecutionState.FAILED:
+ case QueryExecutionState.CANCELLED: {
+ throw new Error(
+ `Athena query ${queryExecutionId} ${state}: ${QueryExecution?.Status?.StateChangeReason ?? "no reason given"}`,
+ );
+ }
+ default: {
+ deps.logger.debug({ queryExecutionId, state }, "Waiting for query");
+ await sleep(pollIntervalMs);
+ }
+ }
+ }
+}
+
+function toRows(
+ page: GetQueryResultsCommandOutput,
+ isFirstPage: boolean,
+): AthenaRow[] {
+ const columns =
+ page.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((c) => c.Name ?? "") ??
+ [];
+ // For SELECT queries the first row of the first page contains the column headers.
+ const rows = (page.ResultSet?.Rows ?? []).slice(isFirstPage ? 1 : 0);
+
+ return rows.map((row) =>
+ Object.fromEntries(
+ columns.map((column, index) => [
+ column,
+ row.Data?.at(index)?.VarCharValue,
+ ]),
+ ),
+ );
+}
+
+async function getResults(
+ deps: Deps,
+ queryExecutionId: string,
+): Promise {
+ const results: AthenaRow[] = [];
+ let nextToken: string | undefined;
+ let isFirstPage = true;
+
+ do {
+ const page = await deps.athenaClient.send(
+ new GetQueryResultsCommand({
+ QueryExecutionId: queryExecutionId,
+ NextToken: nextToken,
+ }),
+ );
+ results.push(...toRows(page, isFirstPage));
+ nextToken = page.NextToken;
+ isFirstPage = false;
+ } while (nextToken);
+
+ return results;
+}
+
+export default async function runAthenaQuery(
+ deps: Deps,
+ query: AthenaQuery,
+ options: AthenaQueryOptions = {},
+): Promise {
+ const queryExecutionId = await startQuery(deps, query);
+ deps.logger.info({ queryExecutionId }, "Started Athena query");
+
+ await waitForQuery(
+ deps,
+ queryExecutionId,
+ options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
+ );
+
+ return getResults(deps, queryExecutionId);
+}
diff --git a/lambdas/letter-sla-breaches/common/src/deps.ts b/lambdas/letter-sla-breaches/common/src/deps.ts
new file mode 100644
index 000000000..fb801c886
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/deps.ts
@@ -0,0 +1,25 @@
+import { AthenaClient } from "@aws-sdk/client-athena";
+import { createLogger } from "@internal/helpers";
+import { Logger } from "pino";
+
+export type Deps = {
+ athenaClient: AthenaClient;
+ logger: Logger;
+ env: {
+ ATHENA_WORKGROUP: string;
+ ATHENA_DATABASE: string;
+ EVENT_CACHE_TABLE_NAME: string;
+ };
+};
+
+export default function createDependenciesContainer(): Deps {
+ return {
+ athenaClient: new AthenaClient(),
+ logger: createLogger({ logLevel: process.env.PINO_LOG_LEVEL }),
+ env: {
+ ATHENA_WORKGROUP: process.env.ATHENA_WORKGROUP!,
+ ATHENA_DATABASE: process.env.ATHENA_DATABASE!,
+ EVENT_CACHE_TABLE_NAME: process.env.EVENT_CACHE_TABLE_NAME!,
+ },
+ };
+}
diff --git a/lambdas/letter-sla-breaches/common/src/index.ts b/lambdas/letter-sla-breaches/common/src/index.ts
new file mode 100644
index 000000000..87d4356e7
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/index.ts
@@ -0,0 +1,5 @@
+export { default as runAthenaQuery } from "./athena";
+export { default as buildAthenaDatePredicate } from "./athena-date-predicate";
+export { default as createDependenciesContainer } from "./deps";
+export type { AthenaQuery, AthenaQueryOptions, AthenaRow } from "./athena";
+export type { Deps } from "./deps";
diff --git a/lambdas/letter-sla-breaches/common/tsconfig.json b/lambdas/letter-sla-breaches/common/tsconfig.json
new file mode 100644
index 000000000..4a11b255a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "ignoreDeprecations": "5.0"
+ },
+ "extends": "../../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore b/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore
new file mode 100644
index 000000000..fc5a7859a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore
@@ -0,0 +1,5 @@
+coverage
+node_modules
+dist
+.reports
+.aws-sam
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts
new file mode 100644
index 000000000..ec5a312bb
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts
@@ -0,0 +1,46 @@
+export const baseJestConfig = {
+ preset: "ts-jest",
+ extensionsToTreatAsEsm: [".ts"],
+ transform: {
+ "^.+\\.ts$": [
+ "ts-jest",
+ {
+ useESM: true,
+ },
+ ],
+ },
+
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "./.reports/unit/coverage",
+ coverageProvider: "babel",
+
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: -10,
+ },
+ },
+
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ testPathIgnorePatterns: [".build"],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+
+ reporters: [
+ "default",
+ [
+ "jest-html-reporter",
+ {
+ pageTitle: "Test Report",
+ outputPath: "./.reports/unit/test-report.html",
+ includeFailureMsg: true,
+ },
+ ],
+ ],
+
+ testEnvironment: "node",
+};
+
+export default baseJestConfig;
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json b/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json
new file mode 100644
index 000000000..43d7dc0da
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json
@@ -0,0 +1,16 @@
+{
+ "dependencies": {
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
+ },
+ "name": "nhs-notify-supplier-api-populate-sameday-breaches",
+ "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/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts
new file mode 100644
index 000000000..fece836ce
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts
@@ -0,0 +1,102 @@
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import createHandler from "../handler/populate-sameday-breaches";
+
+jest.mock("@internal/letter-sla-breaches-common");
+
+const makeDeps = () =>
+ ({
+ logger: { info: jest.fn(), error: jest.fn() },
+ env: { EVENT_CACHE_TABLE_NAME: "event_cache" },
+ }) as unknown as Deps;
+
+describe("populateSamedayBreaches", () => {
+ it("runs the sameday breaches query and returns success", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([{ breach_count: "1" }]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "1" });
+ });
+
+ it("sums the breach count across suppliers", async () => {
+ const deps = makeDeps();
+ jest
+ .mocked(runAthenaQuery)
+ .mockResolvedValueOnce([
+ { supplierid: "a", breach_count: "2" },
+ { supplierid: "b", breach_count: "3" },
+ { supplierid: "c" },
+ ]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "5" });
+ });
+
+ it("builds the query from the date component of the event date", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([]);
+
+ await createHandler(deps)({ date: "2026-10-08T15:30:00.000Z" });
+
+ expect(runAthenaQuery).toHaveBeenCalledWith(
+ deps,
+ expect.objectContaining({
+ executionParameters: ["2026-10-08T00:00:00.000Z"],
+ }),
+ );
+ });
+
+ it("returns failure when the event date is invalid", async () => {
+ const deps = makeDeps();
+
+ const result = await createHandler(deps)({ date: "not-a-date" });
+
+ expect(runAthenaQuery).not.toHaveBeenCalled();
+ expect(result).toEqual(
+ expect.objectContaining({ status: "FAILURE", breachCount: "0" }),
+ );
+ });
+
+ it("returns a zero breach count when the query returns no rows", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "0" });
+ });
+
+ it("runs the sameday breaches query and returns failure", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockRejectedValueOnce(new Error("boom"));
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({
+ failureReason: "boom",
+ status: "FAILURE",
+ breachCount: "0",
+ });
+ });
+
+ it("runs the sameday breaches query and returns failure when not an error", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockRejectedValueOnce("boom");
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({
+ failureReason: "boom",
+ status: "FAILURE",
+ breachCount: "0",
+ });
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/sameday-breaches-query.test.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/sameday-breaches-query.test.ts
new file mode 100644
index 000000000..011bae6bb
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/sameday-breaches-query.test.ts
@@ -0,0 +1,53 @@
+import buildSamedayBreachesQuery from "../handler/sameday-breaches-query";
+
+describe("buildSamedayBreachesQuery", () => {
+ it("selects from the event cache table filtered by the date partitions", () => {
+ expect(
+ buildSamedayBreachesQuery("event_cache", new Date("2026-10-08"), 0),
+ ).toEqual(
+ expect.objectContaining({
+ queryString: expect.stringContaining(
+ "(__year = '2026' AND __month = '10' AND __day IN ('08'))",
+ ),
+ executionParameters: ["2026-10-08T00:00:00.000Z"],
+ }),
+ );
+ });
+
+ it("applies a 24 hour SLA to letters allocated at or after 16:00 UK time", () => {
+ const { queryString } = buildSamedayBreachesQuery(
+ "event_cache",
+ new Date("2026-10-08"),
+ 0,
+ );
+
+ expect(queryString).toContain(
+ "WHEN hour(min(event_time) AT TIME ZONE 'Europe/London') >= 16",
+ );
+ expect(queryString).toContain("THEN min(event_time) + INTERVAL '24' HOUR");
+ });
+
+ it("applies an end of day SLA to letters allocated before 16:00 UK time", () => {
+ const { queryString } = buildSamedayBreachesQuery(
+ "event_cache",
+ new Date("2026-10-08"),
+ 0,
+ );
+
+ expect(queryString).toContain(
+ "ELSE date_trunc('day', min(event_time) AT TIME ZONE 'Europe/London') + INTERVAL '1' DAY",
+ );
+ });
+
+ it("returns the breach count per supplier as breach_count", () => {
+ const { queryString } = buildSamedayBreachesQuery(
+ "event_cache",
+ new Date("2026-10-08"),
+ 0,
+ );
+
+ expect(queryString).toContain(
+ "SELECT supplierid, count(*) AS breach_count",
+ );
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts
new file mode 100644
index 000000000..49e6f9d8e
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts
@@ -0,0 +1,55 @@
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import buildSamedayBreachesQuery from "./sameday-breaches-query";
+
+export type PopulatesamedayBreachesEvent = {
+ date: string;
+};
+
+export type PopulatesamedayBreachesResult = {
+ status: "SUCCESS" | "FAILURE";
+ failureReason?: string;
+ breachCount: string;
+};
+
+export default function createHandler(deps: Deps) {
+ return async (
+ event: PopulatesamedayBreachesEvent,
+ ): Promise => {
+ try {
+ const rows = await runAthenaQuery(
+ deps,
+ buildSamedayBreachesQuery(
+ deps.env.EVENT_CACHE_TABLE_NAME,
+ new Date(event.date.slice(0, 10)),
+ 14,
+ ),
+ );
+
+ const breachCount = String(
+ rows.reduce((total, row) => total + Number(row.breach_count ?? 0), 0),
+ );
+
+ // TODO: CCM-24041 process the query results to populate sameday breaches.
+ deps.logger.info(
+ { date: event.date, breachCount },
+ "sameday breaches query completed",
+ );
+
+ return { status: "SUCCESS", breachCount };
+ } catch (error) {
+ deps.logger.error(
+ { date: event.date, error },
+ "Failed to populate sameday breaches",
+ );
+
+ return {
+ status: "FAILURE",
+ failureReason: error instanceof Error ? error.message : String(error),
+ breachCount: "0",
+ };
+ }
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/sameday-breaches-query.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/sameday-breaches-query.ts
new file mode 100644
index 000000000..411f6d33d
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/sameday-breaches-query.ts
@@ -0,0 +1,47 @@
+import {
+ type AthenaQuery,
+ buildAthenaDatePredicate,
+} from "@internal/letter-sla-breaches-common";
+
+export default function buildSamedayBreachesQuery(
+ tableName: string,
+ now: Date,
+ daysHistory: number,
+): AthenaQuery {
+ return {
+ queryString: `WITH now AS (
+ SELECT from_iso8601_timestamp(?) AS iso8601
+),
+recent_letter_events AS (
+ SELECT domainid, supplierid, status, from_iso8601_timestamp(time) AS event_time
+ FROM ${tableName}
+ WHERE ${buildAthenaDatePredicate(now, daysHistory)}
+),
+allocated_letters AS (
+ SELECT domainid, supplierid
+ FROM recent_letter_events
+ WHERE status = 'PENDING'
+ GROUP BY domainid, supplierid
+ HAVING (SELECT iso8601 FROM now) >
+ CASE
+ WHEN hour(min(event_time) AT TIME ZONE 'Europe/London') >= 16
+ THEN min(event_time) + INTERVAL '24' HOUR
+ ELSE date_trunc('day', min(event_time) AT TIME ZONE 'Europe/London') + INTERVAL '1' DAY
+ END
+),
+completed_letters AS (
+ SELECT domainid, supplierid
+ FROM recent_letter_events
+ WHERE status in ('REJECTED', 'FAILED', 'CANCELLED', 'FORWARDED', 'DISPATCHED')
+ GROUP BY domainid, supplierid
+)
+SELECT supplierid, count(*) AS breach_count
+FROM (
+ SELECT al.domainid, al.supplierid
+ FROM allocated_letters al
+ LEFT JOIN completed_letters cl ON al.domainid = cl.domainid WHERE cl.domainid IS NULL
+)
+GROUP BY supplierid`,
+ executionParameters: [now.toISOString()],
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts
new file mode 100644
index 000000000..65429ac8b
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts
@@ -0,0 +1,7 @@
+import { createDependenciesContainer } from "@internal/letter-sla-breaches-common";
+import createHandler from "./handler/populate-sameday-breaches";
+
+// eslint-disable-next-line import-x/prefer-default-export
+export const populateSamedayBreachesHandler = createHandler(
+ createDependenciesContainer(),
+);
diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json b/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json
new file mode 100644
index 000000000..f4059d5e6
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true
+ },
+ "extends": "../../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore b/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore
new file mode 100644
index 000000000..fc5a7859a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore
@@ -0,0 +1,5 @@
+coverage
+node_modules
+dist
+.reports
+.aws-sam
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts
new file mode 100644
index 000000000..ec5a312bb
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts
@@ -0,0 +1,46 @@
+export const baseJestConfig = {
+ preset: "ts-jest",
+ extensionsToTreatAsEsm: [".ts"],
+ transform: {
+ "^.+\\.ts$": [
+ "ts-jest",
+ {
+ useESM: true,
+ },
+ ],
+ },
+
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "./.reports/unit/coverage",
+ coverageProvider: "babel",
+
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: -10,
+ },
+ },
+
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ testPathIgnorePatterns: [".build"],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+
+ reporters: [
+ "default",
+ [
+ "jest-html-reporter",
+ {
+ pageTitle: "Test Report",
+ outputPath: "./.reports/unit/test-report.html",
+ includeFailureMsg: true,
+ },
+ ],
+ ],
+
+ testEnvironment: "node",
+};
+
+export default baseJestConfig;
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
new file mode 100644
index 000000000..a8a5775a2
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
@@ -0,0 +1,16 @@
+{
+ "dependencies": {
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
+ },
+ "name": "nhs-notify-supplier-api-populate-standard-breaches",
+ "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/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
new file mode 100644
index 000000000..5b6941f68
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
@@ -0,0 +1,102 @@
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import createHandler from "../handler/populate-standard-breaches";
+
+jest.mock("@internal/letter-sla-breaches-common");
+
+const makeDeps = () =>
+ ({
+ logger: { info: jest.fn(), error: jest.fn() },
+ env: { EVENT_CACHE_TABLE_NAME: "event_cache" },
+ }) as unknown as Deps;
+
+describe("populateStandardBreaches", () => {
+ it("runs the standard breaches query and returns success", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([{ breach_count: "1" }]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "1" });
+ });
+
+ it("sums the breach count across suppliers", async () => {
+ const deps = makeDeps();
+ jest
+ .mocked(runAthenaQuery)
+ .mockResolvedValueOnce([
+ { supplierid: "a", breach_count: "2" },
+ { supplierid: "b", breach_count: "3" },
+ { supplierid: "c" },
+ ]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "5" });
+ });
+
+ it("builds the query from the date component of the event date", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([]);
+
+ await createHandler(deps)({ date: "2026-10-08T15:30:00.000Z" });
+
+ expect(runAthenaQuery).toHaveBeenCalledWith(
+ deps,
+ expect.objectContaining({
+ executionParameters: ["2026-10-08T00:00:00.000Z"],
+ }),
+ );
+ });
+
+ it("returns failure when the event date is invalid", async () => {
+ const deps = makeDeps();
+
+ const result = await createHandler(deps)({ date: "not-a-date" });
+
+ expect(runAthenaQuery).not.toHaveBeenCalled();
+ expect(result).toEqual(
+ expect.objectContaining({ status: "FAILURE", breachCount: "0" }),
+ );
+ });
+
+ it("returns a zero breach count when the query returns no rows", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValueOnce([]);
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(result).toEqual({ status: "SUCCESS", breachCount: "0" });
+ });
+
+ it("runs the standard breaches query and returns failure", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockRejectedValueOnce(new Error("boom"));
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({
+ failureReason: "boom",
+ status: "FAILURE",
+ breachCount: "0",
+ });
+ });
+
+ it("runs the standard breaches query and returns failure when not an error", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockRejectedValueOnce("boom");
+
+ const result = await createHandler(deps)({ date: "2026-10-08" });
+
+ expect(runAthenaQuery).toHaveBeenCalled();
+ expect(result).toEqual({
+ failureReason: "boom",
+ status: "FAILURE",
+ breachCount: "0",
+ });
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts
new file mode 100644
index 000000000..ff1f807e1
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts
@@ -0,0 +1,16 @@
+import buildStandardBreachesQuery from "../handler/standard-breaches-query";
+
+describe("buildStandardBreachesQuery", () => {
+ it("selects from the event cache table filtered by the date partitions", () => {
+ expect(
+ buildStandardBreachesQuery("event_cache", new Date("2026-10-08"), 0),
+ ).toEqual(
+ expect.objectContaining({
+ queryString: expect.stringContaining(
+ "(__year = '2026' AND __month = '10' AND __day IN ('08'))",
+ ),
+ executionParameters: ["2026-10-08T00:00:00.000Z"],
+ }),
+ );
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
new file mode 100644
index 000000000..873490b34
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
@@ -0,0 +1,55 @@
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import buildStandardBreachesQuery from "./standard-breaches-query";
+
+export type PopulateStandardBreachesEvent = {
+ date: string;
+};
+
+export type PopulateStandardBreachesResult = {
+ status: "SUCCESS" | "FAILURE";
+ failureReason?: string;
+ breachCount: string;
+};
+
+export default function createHandler(deps: Deps) {
+ return async (
+ event: PopulateStandardBreachesEvent,
+ ): Promise => {
+ try {
+ const rows = await runAthenaQuery(
+ deps,
+ buildStandardBreachesQuery(
+ deps.env.EVENT_CACHE_TABLE_NAME,
+ new Date(event.date.slice(0, 10)),
+ 14,
+ ),
+ );
+
+ const breachCount = String(
+ rows.reduce((total, row) => total + Number(row.breach_count ?? 0), 0),
+ );
+
+ // TODO: CCM-24041 process the query results to populate standard breaches.
+ deps.logger.info(
+ { date: event.date, breachCount },
+ "Standard breaches query completed",
+ );
+
+ return { status: "SUCCESS", breachCount };
+ } catch (error) {
+ deps.logger.error(
+ { date: event.date, error },
+ "Failed to populate standard breaches",
+ );
+
+ return {
+ status: "FAILURE",
+ failureReason: error instanceof Error ? error.message : String(error),
+ breachCount: "0",
+ };
+ }
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts
new file mode 100644
index 000000000..871db1363
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts
@@ -0,0 +1,43 @@
+import {
+ type AthenaQuery,
+ buildAthenaDatePredicate,
+} from "@internal/letter-sla-breaches-common";
+
+export default function buildStandardBreachesQuery(
+ tableName: string,
+ now: Date,
+ daysHistory: number,
+): AthenaQuery {
+ return {
+ queryString: `WITH now AS (
+ SELECT from_iso8601_timestamp(?) AS iso8601
+),
+recent_letter_events AS (
+ SELECT domainid, supplierid, status, from_iso8601_timestamp(time) AS event_time
+ FROM ${tableName}
+ WHERE ${buildAthenaDatePredicate(now, daysHistory)}
+),
+allocated_letters AS (
+ SELECT domainid, supplierid
+ FROM recent_letter_events
+ WHERE status = 'PENDING'
+ GROUP BY domainid, supplierid
+ HAVING min(event_time) < (SELECT iso8601 FROM now) -
+ CASE WHEN day_of_week((SELECT iso8601 AT TIME ZONE 'Europe/London' FROM now)) <= 2 THEN INTERVAL '96' HOUR ELSE INTERVAL '48' HOUR END
+),
+completed_letters AS (
+ SELECT domainid, supplierid
+ FROM recent_letter_events
+ WHERE status in ('REJECTED', 'FAILED', 'CANCELLED', 'FORWARDED', 'DISPATCHED')
+ GROUP BY domainid, supplierid
+)
+SELECT supplierid, count(*) AS breach_count
+FROM (
+ SELECT al.domainid, al.supplierid
+ FROM allocated_letters al
+ LEFT JOIN completed_letters cl ON al.domainid = cl.domainid WHERE cl.domainid IS NULL
+)
+GROUP BY supplierid`,
+ executionParameters: [now.toISOString()],
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts
new file mode 100644
index 000000000..f52283ddb
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts
@@ -0,0 +1,7 @@
+import { createDependenciesContainer } from "@internal/letter-sla-breaches-common";
+import createHandler from "./handler/populate-standard-breaches";
+
+// eslint-disable-next-line import-x/prefer-default-export
+export const populateStandardBreachesHandler = createHandler(
+ createDependenciesContainer(),
+);
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json b/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json
new file mode 100644
index 000000000..4a11b255a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "ignoreDeprecations": "5.0"
+ },
+ "extends": "../../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore b/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore
new file mode 100644
index 000000000..fc5a7859a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore
@@ -0,0 +1,5 @@
+coverage
+node_modules
+dist
+.reports
+.aws-sam
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts
new file mode 100644
index 000000000..ec5a312bb
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts
@@ -0,0 +1,46 @@
+export const baseJestConfig = {
+ preset: "ts-jest",
+ extensionsToTreatAsEsm: [".ts"],
+ transform: {
+ "^.+\\.ts$": [
+ "ts-jest",
+ {
+ useESM: true,
+ },
+ ],
+ },
+
+ clearMocks: true,
+ collectCoverage: true,
+ coverageDirectory: "./.reports/unit/coverage",
+ coverageProvider: "babel",
+
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: -10,
+ },
+ },
+
+ coveragePathIgnorePatterns: ["/__tests__/"],
+ testPathIgnorePatterns: [".build"],
+ testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
+
+ reporters: [
+ "default",
+ [
+ "jest-html-reporter",
+ {
+ pageTitle: "Test Report",
+ outputPath: "./.reports/unit/test-report.html",
+ includeFailureMsg: true,
+ },
+ ],
+ ],
+
+ testEnvironment: "node",
+};
+
+export default baseJestConfig;
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json
new file mode 100644
index 000000000..5667e2485
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json
@@ -0,0 +1,22 @@
+{
+ "dependencies": {
+ "@aws-sdk/client-dynamodb": "^3.1138.0",
+ "@aws-sdk/client-s3": "^3.1138.0",
+ "@aws-sdk/lib-dynamodb": "^3.1138.0",
+ "@internal/datastore": "^0.1.0",
+ "@internal/helpers": "^0.1.0",
+ "esbuild": "0.28.2",
+ "pino": "^10.3.1",
+ "zod": "^4.6.5"
+ },
+ "name": "nhs-notify-supplier-api-populate-supplier-config-cache",
+ "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/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts
new file mode 100644
index 000000000..2d68709f7
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts
@@ -0,0 +1,95 @@
+import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
+import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
+import populateEntityCache, { EntityCacheDeps } from "../handler/entity-cache";
+
+function makeDeps(ddbSend: jest.Mock, s3Send: jest.Mock): EntityCacheDeps {
+ return {
+ ddbClient: { send: ddbSend } as unknown as DynamoDBDocumentClient,
+ s3Client: { send: s3Send } as unknown as S3Client,
+ logger: { info: jest.fn() } as unknown as EntityCacheDeps["logger"],
+ env: {
+ SUPPLIER_CONFIG_TABLE_NAME: "config-table",
+ EVENT_CACHE_BUCKET_NAME: "cache-bucket",
+ },
+ };
+}
+
+describe("populateEntityCache", () => {
+ it("queries all pages for the entity and writes them to S3", async () => {
+ const ddbSend = jest
+ .fn()
+ .mockResolvedValueOnce({
+ Items: [{ pk: "ENTITY#supplier", sk: "ID#1" }],
+ LastEvaluatedKey: { pk: "ENTITY#supplier", sk: "ID#1" },
+ })
+ .mockResolvedValueOnce({
+ Items: [{ pk: "ENTITY#supplier", sk: "ID#2" }],
+ });
+ const s3Send = jest.fn().mockResolvedValue({});
+ const deps = makeDeps(ddbSend, s3Send);
+
+ const count = await populateEntityCache(deps, "supplier");
+
+ expect(count).toBe(2);
+ expect(ddbSend).toHaveBeenCalledTimes(2);
+ const firstQuery = ddbSend.mock.calls[0][0] as QueryCommand;
+ expect(firstQuery.input).toEqual({
+ TableName: "config-table",
+ KeyConditionExpression: "pk = :pk",
+ ExpressionAttributeValues: { ":pk": "ENTITY#supplier" },
+ ExclusiveStartKey: undefined,
+ });
+ const secondQuery = ddbSend.mock.calls[1][0] as QueryCommand;
+ expect(secondQuery.input.ExclusiveStartKey).toEqual({
+ pk: "ENTITY#supplier",
+ sk: "ID#1",
+ });
+
+ const put = s3Send.mock.calls[0][0] as PutObjectCommand;
+ expect(put.input.Bucket).toBe("cache-bucket");
+ expect(put.input.Key).toBe("supplier_config_cache/supplier/records.json");
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ { entityType: "supplier" },
+ "Loading supplier config entity",
+ );
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ { entityType: "supplier", itemCount: 2, hasItems: true },
+ "Loaded supplier config entity",
+ );
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ {
+ entityType: "supplier",
+ itemCount: 2,
+ bucket: "cache-bucket",
+ key: "supplier_config_cache/supplier/records.json",
+ s3Uri: "s3://cache-bucket/supplier_config_cache/supplier/records.json",
+ },
+ "Created supplier config cache file",
+ );
+ expect(
+ (put.input.Body as string).split("\n").map((line) => JSON.parse(line)),
+ ).toEqual([
+ { pk: "ENTITY#supplier", sk: "ID#1" },
+ { pk: "ENTITY#supplier", sk: "ID#2" },
+ ]);
+ });
+
+ it("writes an empty file when no rows are returned", async () => {
+ const ddbSend = jest.fn().mockResolvedValue({});
+ const s3Send = jest.fn().mockResolvedValue({});
+ const deps = makeDeps(ddbSend, s3Send);
+
+ const count = await populateEntityCache(deps, "volume-group");
+
+ expect(count).toBe(0);
+ const put = s3Send.mock.calls[0][0] as PutObjectCommand;
+ expect(put.input.Key).toBe(
+ "supplier_config_cache/volume-group/records.json",
+ );
+ expect(put.input.Body).toBe("");
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ { entityType: "volume-group", itemCount: 0, hasItems: false },
+ "Loaded supplier config entity",
+ );
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts
new file mode 100644
index 000000000..ee6aafe5c
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts
@@ -0,0 +1,48 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+/* Allow require imports to enable re-import of modules */
+
+import { ZodError } from "zod";
+
+describe("lambdaEnv", () => {
+ const OLD_ENV = process.env;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...OLD_ENV };
+ });
+
+ afterAll(() => {
+ process.env = OLD_ENV;
+ });
+
+ it("loads required and optional environment variables", () => {
+ process.env.SUPPLIER_CONFIG_TABLE_NAME = "supplier-config-table";
+ process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket";
+ process.env.PINO_LOG_LEVEL = "debug";
+
+ const { envVars } = require("../handler/env");
+
+ expect(envVars).toEqual({
+ SUPPLIER_CONFIG_TABLE_NAME: "supplier-config-table",
+ EVENT_CACHE_BUCKET_NAME: "event-cache-bucket",
+ PINO_LOG_LEVEL: "debug",
+ });
+ });
+
+ it("throws when a required environment variable is missing", () => {
+ process.env.SUPPLIER_CONFIG_TABLE_NAME = undefined;
+ process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket";
+
+ expect(() => require("../handler/env")).toThrow(ZodError);
+ });
+
+ it("allows the optional log level to be unset", () => {
+ process.env.SUPPLIER_CONFIG_TABLE_NAME = "supplier-config-table";
+ process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket";
+ process.env.PINO_LOG_LEVEL = undefined;
+
+ const { envVars } = require("../handler/env");
+
+ expect(envVars.PINO_LOG_LEVEL).toBeUndefined();
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts
new file mode 100644
index 000000000..047694df6
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts
@@ -0,0 +1,66 @@
+import { $SupplierConfigEntity } from "@internal/datastore";
+import populateEntityCache, { EntityCacheDeps } from "../handler/entity-cache";
+import createHandler from "../handler/populate-supplier-config-cache";
+
+jest.mock("@internal/datastore", () => ({
+ $SupplierConfigEntity: {
+ options: [
+ "letter-variant",
+ "volume-group",
+ "supplier-allocation",
+ "supplier",
+ "pack-specification",
+ "supplier-pack",
+ ],
+ },
+}));
+jest.mock("../handler/entity-cache");
+
+describe("populateSupplierConfigCache", () => {
+ it("populates the cache once for each entity type and returns success", async () => {
+ const deps = { logger: { error: jest.fn() } } as unknown as EntityCacheDeps;
+ const entityTypes = $SupplierConfigEntity.options;
+ jest.mocked(populateEntityCache).mockReset();
+ for (const [index] of entityTypes.entries()) {
+ jest.mocked(populateEntityCache).mockResolvedValueOnce(index + 1);
+ }
+ const handler = createHandler(deps);
+
+ const result = await handler();
+
+ expect(populateEntityCache).toHaveBeenCalledTimes(entityTypes.length);
+ for (const entityType of entityTypes) {
+ expect(populateEntityCache).toHaveBeenCalledWith(deps, entityType);
+ }
+ expect(result).toEqual({
+ status: "SUCCESS",
+ });
+ });
+
+ it("returns failure when an entity fails", async () => {
+ const deps = { logger: { error: jest.fn() } } as unknown as EntityCacheDeps;
+ const entityTypes = $SupplierConfigEntity.options;
+ jest.mocked(populateEntityCache).mockReset();
+ for (const [index] of entityTypes.entries()) {
+ if (index === 1) {
+ jest
+ .mocked(populateEntityCache)
+ .mockRejectedValueOnce(new Error("query failed"));
+ } else {
+ jest.mocked(populateEntityCache).mockResolvedValueOnce(index + 1);
+ }
+ }
+ const handler = createHandler(deps);
+
+ const result = await handler();
+
+ expect(result).toEqual({
+ status: "FAILURE",
+ failureReason: `Failed to populate supplier config cache for: ${entityTypes[1]}`,
+ });
+ expect(deps.logger.error).toHaveBeenCalledWith(
+ { entityType: entityTypes[1], error: expect.any(Error) },
+ "Failed to populate supplier config entity cache",
+ );
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts
new file mode 100644
index 000000000..9be803265
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts
@@ -0,0 +1,18 @@
+import { S3Client } from "@aws-sdk/client-s3";
+import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
+import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
+import { createLogger } from "@internal/helpers";
+import { EntityCacheDeps } from "./entity-cache";
+import { envVars } from "./env";
+
+export default function createDependenciesContainer(): EntityCacheDeps {
+ return {
+ ddbClient: DynamoDBDocumentClient.from(new DynamoDBClient()),
+ s3Client: new S3Client(),
+ logger: createLogger({ logLevel: envVars.PINO_LOG_LEVEL }),
+ env: {
+ SUPPLIER_CONFIG_TABLE_NAME: envVars.SUPPLIER_CONFIG_TABLE_NAME,
+ EVENT_CACHE_BUCKET_NAME: envVars.EVENT_CACHE_BUCKET_NAME,
+ },
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts
new file mode 100644
index 000000000..eb4333e9a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts
@@ -0,0 +1,70 @@
+import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
+import {
+ DynamoDBDocumentClient,
+ QueryCommand,
+ QueryCommandOutput,
+} from "@aws-sdk/lib-dynamodb";
+import { Logger } from "pino";
+
+export type EntityCacheDeps = {
+ ddbClient: DynamoDBDocumentClient;
+ s3Client: S3Client;
+ logger: Logger;
+ env: {
+ SUPPLIER_CONFIG_TABLE_NAME: string;
+ EVENT_CACHE_BUCKET_NAME: string;
+ };
+};
+
+export const CACHE_FOLDER = "supplier_config_cache";
+
+export default async function populateEntityCache(
+ deps: EntityCacheDeps,
+ entityType: string,
+): Promise {
+ deps.logger.info({ entityType }, "Loading supplier config entity");
+
+ const items: Record[] = [];
+ let exclusiveStartKey: QueryCommandOutput["LastEvaluatedKey"];
+
+ do {
+ const page: QueryCommandOutput = await deps.ddbClient.send(
+ new QueryCommand({
+ TableName: deps.env.SUPPLIER_CONFIG_TABLE_NAME,
+ KeyConditionExpression: "pk = :pk",
+ ExpressionAttributeValues: { ":pk": `ENTITY#${entityType}` },
+ ExclusiveStartKey: exclusiveStartKey,
+ }),
+ );
+ items.push(...(page.Items ?? []));
+ exclusiveStartKey = page.LastEvaluatedKey;
+ } while (exclusiveStartKey);
+
+ deps.logger.info(
+ { entityType, itemCount: items.length, hasItems: items.length > 0 },
+ "Loaded supplier config entity",
+ );
+
+ const key = `${CACHE_FOLDER}/${entityType}/records.json`;
+ await deps.s3Client.send(
+ new PutObjectCommand({
+ Bucket: deps.env.EVENT_CACHE_BUCKET_NAME,
+ Key: key,
+ Body: items.map((item) => JSON.stringify(item)).join("\n"),
+ ContentType: "application/json",
+ }),
+ );
+
+ deps.logger.info(
+ {
+ entityType,
+ itemCount: items.length,
+ bucket: deps.env.EVENT_CACHE_BUCKET_NAME,
+ key,
+ s3Uri: `s3://${deps.env.EVENT_CACHE_BUCKET_NAME}/${key}`,
+ },
+ "Created supplier config cache file",
+ );
+
+ return items.length;
+}
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts
new file mode 100644
index 000000000..03be4f276
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+const EnvVarsSchema = z.object({
+ SUPPLIER_CONFIG_TABLE_NAME: z.string(),
+ EVENT_CACHE_BUCKET_NAME: z.string(),
+ PINO_LOG_LEVEL: z.string().optional(),
+});
+
+export type EnvVars = z.infer;
+
+export const envVars = EnvVarsSchema.parse(process.env);
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts
new file mode 100644
index 000000000..8ce432a6c
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts
@@ -0,0 +1,45 @@
+import { $SupplierConfigEntity } from "@internal/datastore";
+import populateEntityCache, { EntityCacheDeps } from "./entity-cache";
+
+export type PopulateSupplierConfigCacheResult = {
+ status: "SUCCESS" | "FAILURE";
+ failureReason?: string;
+};
+
+const ENTITY_TYPES = $SupplierConfigEntity.options;
+
+export default function createHandler(deps: EntityCacheDeps) {
+ return async (): Promise => {
+ const results = await Promise.all(
+ ENTITY_TYPES.map(async (entityType) => {
+ try {
+ return {
+ entity: entityType,
+ recordsRetrieved: await populateEntityCache(deps, entityType),
+ failed: false,
+ };
+ } catch (error) {
+ deps.logger.error(
+ { entityType, error },
+ "Failed to populate supplier config entity cache",
+ );
+ return { entity: entityType, recordsRetrieved: 0, failed: true };
+ }
+ }),
+ );
+ const failedEntities = results
+ .filter((result) => result.failed)
+ .map((result) => result.entity);
+
+ if (failedEntities.length > 0) {
+ return {
+ status: "FAILURE",
+ failureReason: `Failed to populate supplier config cache for: ${failedEntities.join(", ")}`,
+ };
+ }
+
+ return {
+ status: "SUCCESS",
+ };
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts
new file mode 100644
index 000000000..fd06c3b3f
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts
@@ -0,0 +1,7 @@
+import createDependenciesContainer from "./handler/deps";
+import createHandler from "./handler/populate-supplier-config-cache";
+
+// eslint-disable-next-line import-x/prefer-default-export
+export const populateSupplierConfigCacheHandler = createHandler(
+ createDependenciesContainer(),
+);
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json
new file mode 100644
index 000000000..4a11b255a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "ignoreDeprecations": "5.0"
+ },
+ "extends": "../../../tsconfig.base.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index cae5e3cea..29e96e4eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"docs",
"internal/*",
"lambdas/*",
+ "lambdas/letter-sla-breaches/*",
"pact-contracts",
"scripts/utilities/*",
"tests",
@@ -155,30 +156,6 @@
"node": ">=20.0.0"
}
},
- "internal/datastore/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "internal/datastore/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"internal/datastore/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -196,33 +173,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "internal/datastore/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "internal/datastore/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"internal/datastore/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": {
"version": "1.1.1",
"dependencies": {
@@ -241,7 +191,7 @@
},
"internal/events": {
"name": "@nhsdigital/nhs-notify-event-schemas-supplier-api",
- "version": "1.0.21",
+ "version": "1.0.22",
"license": "MIT",
"dependencies": {
"@asyncapi/bundler": "^1.0.2",
@@ -365,30 +315,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/api-handler/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/api-handler/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/api-handler/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -406,20 +332,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/api-handler/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/api-handler/node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.43",
"license": "Apache-2.0",
@@ -433,19 +345,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/api-handler/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/authorizer": {
"name": "nhs-notify-supplier-authorizer",
"version": "0.0.1",
@@ -480,30 +379,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/authorizer/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/authorizer/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/authorizer/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -521,232 +396,134 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/authorizer/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/authorizer/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
+ "lambdas/eventpub-parquet-transformer": {
+ "name": "nhs-notify-supplier-api-eventpub-parquet-transformer",
+ "version": "0.0.1",
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
+ "aws-lambda": "^1.0.7",
+ "esbuild": "0.28.2"
}
},
- "lambdas/letter-updates-transformer": {
- "name": "nhs-notify-supplier-api-letter-updates-transformer",
+ "lambdas/letter-sla-breaches/common": {
+ "name": "@internal/letter-sla-breaches-common",
"version": "0.0.1",
+ "license": "MIT",
"dependencies": {
- "@aws-sdk/client-sns": "^3.1138.0",
- "@aws-sdk/util-dynamodb": "^3.996.9",
- "@internal/datastore": "^0.1.0",
- "@internal/event-builders": "*",
+ "@aws-sdk/client-athena": "^3.1149.0",
"@internal/helpers": "^0.1.0",
- "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
- "aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.7",
- "esbuild": "0.28.2",
- "pino": "^10.3.1",
- "zod": "^4.6.5"
+ "pino": "^10.4.0"
}
},
- "lambdas/letter-updates-transformer/node_modules/@aws-sdk/client-dynamodb": {
- "version": "3.1146.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1146.0.tgz",
- "integrity": "sha512-3Mc8YkSu2KlgNNSuspaW+Fo0pTwX+fLYlC5ovH3yX3OoDg2RPtp3Py8KuQJr0FDlQj1tHw0K3+AtaH4NXlI3aQ==",
- "license": "Apache-2.0",
- "peer": true,
+ "lambdas/letter-sla-breaches/common/node_modules/pino": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz",
+ "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==",
+ "license": "MIT",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/credential-provider-node": "^3.972.84",
- "@aws-sdk/dynamodb-codec": "^3.973.46",
- "@aws-sdk/middleware-endpoint-discovery": "^3.972.31",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/fetch-http-handler": "^5.8.0",
- "@smithy/node-http-handler": "^4.12.1",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^1.0.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "bin": {
+ "pino": "bin.js"
}
},
- "lambdas/letter-updates-transformer/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz",
- "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==",
- "license": "Apache-2.0",
- "peer": true,
+ "lambdas/letter-sla-breaches/common/node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "license": "MIT",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
+ "split2": "^4.0.0"
}
},
- "lambdas/letter-updates-transformer/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz",
- "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==",
- "license": "Apache-2.0",
- "peer": true,
+ "lambdas/letter-sla-breaches/common/node_modules/real-require": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
+ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
+ "license": "MIT"
+ },
+ "lambdas/letter-sla-breaches/common/node_modules/thread-stream": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
+ "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
+ "license": "MIT",
"dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
+ "real-require": "^1.0.0"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=20"
}
},
- "lambdas/letter-updates-transformer/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz",
- "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==",
- "license": "Apache-2.0",
- "peer": true,
+ "lambdas/letter-sla-breaches/populate-sameday-breaches": {
+ "name": "nhs-notify-supplier-api-populate-sameday-breaches",
+ "version": "0.0.1",
"dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
}
},
- "lambdas/letter-updates-transformer/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz",
- "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==",
- "license": "Apache-2.0",
+ "lambdas/letter-sla-breaches/populate-standard-breaches": {
+ "name": "nhs-notify-supplier-api-populate-standard-breaches",
+ "version": "0.0.1",
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
}
},
- "lambdas/mi-updates-transformer": {
- "name": "nhs-notify-supplier-api-mi-updates-transformer",
+ "lambdas/letter-sla-breaches/populate-supplier-config-cache": {
+ "name": "nhs-notify-supplier-api-populate-supplier-config-cache",
"version": "0.0.1",
"dependencies": {
- "@aws-sdk/client-sns": "^3.1138.0",
- "@aws-sdk/util-dynamodb": "^3.996.9",
+ "@aws-sdk/client-dynamodb": "^3.1138.0",
+ "@aws-sdk/client-s3": "^3.1138.0",
+ "@aws-sdk/lib-dynamodb": "^3.1138.0",
"@internal/datastore": "^0.1.0",
- "@internal/helpers": "*",
- "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
- "aws-embedded-metrics": "^4.2.1",
- "aws-lambda": "^1.0.7",
+ "@internal/helpers": "^0.1.0",
"esbuild": "0.28.2",
"pino": "^10.3.1",
"zod": "^4.6.5"
}
},
- "lambdas/mi-updates-transformer/node_modules/@aws-sdk/client-dynamodb": {
- "version": "3.1146.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1146.0.tgz",
- "integrity": "sha512-3Mc8YkSu2KlgNNSuspaW+Fo0pTwX+fLYlC5ovH3yX3OoDg2RPtp3Py8KuQJr0FDlQj1tHw0K3+AtaH4NXlI3aQ==",
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/credential-provider-node": "^3.972.84",
- "@aws-sdk/dynamodb-codec": "^3.973.46",
- "@aws-sdk/middleware-endpoint-discovery": "^3.972.31",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/fetch-http-handler": "^5.8.0",
- "@smithy/node-http-handler": "^4.12.1",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/mi-updates-transformer/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz",
- "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==",
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/mi-updates-transformer/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz",
- "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==",
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/mi-updates-transformer/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz",
- "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==",
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
+ "lambdas/letter-updates-transformer": {
+ "name": "nhs-notify-supplier-api-letter-updates-transformer",
+ "version": "0.0.1",
+ "dependencies": {
+ "@aws-sdk/client-sns": "^3.1138.0",
+ "@aws-sdk/util-dynamodb": "^3.996.9",
+ "@internal/datastore": "^0.1.0",
+ "@internal/event-builders": "*",
+ "@internal/helpers": "^0.1.0",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
+ "aws-embedded-metrics": "^4.2.1",
+ "aws-lambda": "^1.0.7",
+ "esbuild": "0.28.2",
+ "pino": "^10.3.1",
+ "zod": "^4.6.5"
}
},
- "lambdas/mi-updates-transformer/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz",
- "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==",
- "license": "Apache-2.0",
+ "lambdas/mi-updates-transformer": {
+ "name": "nhs-notify-supplier-api-mi-updates-transformer",
+ "version": "0.0.1",
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
+ "@aws-sdk/client-sns": "^3.1138.0",
+ "@aws-sdk/util-dynamodb": "^3.996.9",
+ "@internal/datastore": "^0.1.0",
+ "@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
+ "aws-embedded-metrics": "^4.2.1",
+ "aws-lambda": "^1.0.7",
+ "esbuild": "0.28.2",
+ "pino": "^10.3.1",
+ "zod": "^4.6.5"
}
},
"lambdas/supplier-allocator": {
@@ -856,30 +633,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/supplier-allocator/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/supplier-allocator/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/supplier-allocator/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -897,20 +650,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/supplier-allocator/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/supplier-allocator/node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.43",
"license": "Apache-2.0",
@@ -924,19 +663,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/supplier-allocator/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/supplier-allocator/node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"license": "Apache-2.0",
@@ -1014,30 +740,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/supplier-config-ingress/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/supplier-config-ingress/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/supplier-config-ingress/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -1055,33 +757,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/supplier-config-ingress/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/supplier-config-ingress/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/supplier-config-ingress/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": {
"version": "1.1.1",
"dependencies": {
@@ -1167,30 +842,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/update-letter-queue/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/update-letter-queue/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/update-letter-queue/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -1208,33 +859,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/update-letter-queue/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/update-letter-queue/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/upsert-letter": {
"name": "nhs-notify-supplier-api-upsert-letter",
"version": "0.0.1",
@@ -1320,30 +944,6 @@
"node": ">=20.0.0"
}
},
- "lambdas/upsert-letter/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/upsert-letter/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"lambdas/upsert-letter/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -1361,33 +961,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/upsert-letter/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "lambdas/upsert-letter/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/upsert-letter/node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"license": "Apache-2.0",
@@ -1490,9 +1063,46 @@
"version": "3.1001.1",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/types": "^3.974.6",
+ "@smithy/core": "^3.35.0",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-api-gateway": {
+ "version": "3.1145.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/credential-provider-node": "^3.972.84",
+ "@aws-sdk/middleware-sdk-api-gateway": "^3.972.30",
+ "@aws-sdk/types": "^3.974.6",
+ "@smithy/core": "^3.35.0",
+ "@smithy/fetch-http-handler": "^5.8.0",
+ "@smithy/node-http-handler": "^4.12.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-athena": {
+ "version": "3.1152.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-athena/-/client-athena-3.1152.0.tgz",
+ "integrity": "sha512-Yk6ml5I2+N2jQBfhuQ8RXYGv9Fhn/qMJIIdkMQ+UFfT2ZM0Cg3Dzr+b2ZozluHw0SsNV5S6NohXIEZBgcY2kVg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/credential-provider-node": "^3.972.86",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
+ "@smithy/fetch-http-handler": "^5.8.0",
+ "@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1500,13 +1110,13 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/client-api-gateway": {
- "version": "3.1145.0",
+ "node_modules/@aws-sdk/client-cloudwatch-logs": {
+ "version": "3.1146.0",
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
- "@aws-sdk/middleware-sdk-api-gateway": "^3.972.30",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
@@ -1518,13 +1128,16 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/client-cloudwatch-logs": {
- "version": "3.1146.0",
- "dev": true,
+ "node_modules/@aws-sdk/client-dynamodb": {
+ "version": "3.1147.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1147.0.tgz",
+ "integrity": "sha512-lONixsTyj2jt5bb14ir4VYuBEy8kMNMZt7DSVxZ4HHE89hEfND5lO4Qj+F3Z74sENdFCaSAgK8RYQaiQWm5pfw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
+ "@aws-sdk/dynamodb-codec": "^3.973.46",
+ "@aws-sdk/middleware-endpoint-discovery": "^3.972.31",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
@@ -1625,14 +1238,16 @@
}
},
"node_modules/@aws-sdk/core": {
- "version": "3.978.1",
+ "version": "3.978.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.3.tgz",
+ "integrity": "sha512-nslpzUCTI9b2IQMWSd6mSAXldBexPc8rr0d3dXUutXYFLFaTdsHa2GD7sJdsn3PhyjK0O5K8ypd6zi+RXXPVlg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
- "@smithy/core": "^3.35.0",
- "@smithy/signature-v4": "^5.7.3",
+ "@smithy/core": "^3.35.2",
+ "@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@@ -1649,12 +1264,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.72",
+ "version": "3.972.74",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.74.tgz",
+ "integrity": "sha512-rsRJcW2iFUhCgclyD6OSbMEaPl8Eu0qSeRZb2uuCK1ViZ66hl5wOe/p6Vr6MO3b+ydyKgFeqza2p6tZThvILlw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.3",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1663,12 +1280,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.74",
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.76.tgz",
+ "integrity": "sha512-pnKdh0Br7OJPj+7qKjzgTfDTFW0RhEMKXNDR2jv9CAM4oHuG/4z3hRh8KtutLfpmItuRtDbKBmwWkDqHwwAWXw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.3",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -1679,19 +1298,21 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.973.17",
+ "version": "3.973.19",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.19.tgz",
+ "integrity": "sha512-kcjvAxqQEV8yuwkX58RelNn3ne65o7JgRWf3Z/gVvbi7Xuw1qSDLJlatrLD76+40cmRxmNkLxBoyp/vAQChQ5g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/credential-provider-env": "^3.972.72",
- "@aws-sdk/credential-provider-http": "^3.972.74",
- "@aws-sdk/credential-provider-login": "^3.972.79",
- "@aws-sdk/credential-provider-process": "^3.972.72",
- "@aws-sdk/credential-provider-sso": "^3.973.16",
- "@aws-sdk/credential-provider-web-identity": "^3.972.78",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/credential-provider-env": "^3.972.74",
+ "@aws-sdk/credential-provider-http": "^3.972.76",
+ "@aws-sdk/credential-provider-login": "^3.972.81",
+ "@aws-sdk/credential-provider-process": "^3.972.74",
+ "@aws-sdk/credential-provider-sso": "^3.973.18",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.80",
+ "@aws-sdk/nested-clients": "^3.997.48",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -1701,13 +1322,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.79",
+ "version": "3.972.81",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.81.tgz",
+ "integrity": "sha512-CeQHSbE2CnVdaHIJ/NayRaCecIER+Pm9faUPkmRATO54rCoMTEPSbVieGHzEwvm1dZM2HIs6qQZCznhayNTWJQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/nested-clients": "^3.997.48",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1716,17 +1339,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.84",
+ "version": "3.972.86",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.86.tgz",
+ "integrity": "sha512-WNttSyr6e8innPtBSty//CdnqJcgZN6eAbtw3v/CR2IObz6MYZEo8eusdtuCHJ2ujwFqC2bSPHVyhXGHx8HruQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.72",
- "@aws-sdk/credential-provider-http": "^3.972.74",
- "@aws-sdk/credential-provider-ini": "^3.973.17",
- "@aws-sdk/credential-provider-process": "^3.972.72",
- "@aws-sdk/credential-provider-sso": "^3.973.16",
- "@aws-sdk/credential-provider-web-identity": "^3.972.78",
+ "@aws-sdk/credential-provider-env": "^3.972.74",
+ "@aws-sdk/credential-provider-http": "^3.972.76",
+ "@aws-sdk/credential-provider-ini": "^3.973.19",
+ "@aws-sdk/credential-provider-process": "^3.972.74",
+ "@aws-sdk/credential-provider-sso": "^3.973.18",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.80",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -1736,12 +1361,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.72",
+ "version": "3.972.74",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.74.tgz",
+ "integrity": "sha512-Yj6kZFJQdbR+PygTkqdVLPHHpdDpOS9GCYVcDCuweCRRig9LF24ELQBMfyUw1vspvrZJUfnOGVyMjhaL094ahw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.3",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1750,14 +1377,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.973.16",
+ "version": "3.973.18",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.18.tgz",
+ "integrity": "sha512-FnpPhdY4qUGtX/8X6eKxh+uD+9Y19i9FYibgk4SBe0M935s9vocHkHzaxaMNk+YkHmvzgdu8cHA5SCoeU+kkew==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
- "@aws-sdk/token-providers": "3.1138.0",
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/nested-clients": "^3.997.48",
+ "@aws-sdk/token-providers": "3.1150.0",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1766,11 +1395,76 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.78",
+ "version": "3.972.80",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.80.tgz",
+ "integrity": "sha512-TJF25Ndwy2HxcVj6oxMgp8qVlJAawlExHYBXqhuC6Zt2m+A7WpdkjBD6QglNbiArlteYljZHEY3R7KKmno6ISA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/nested-clients": "^3.997.48",
+ "@aws-sdk/types": "^3.974.6",
+ "@smithy/core": "^3.35.2",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/dynamodb-codec": {
+ "version": "3.973.46",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz",
+ "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.1",
+ "@smithy/core": "^3.35.0",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/endpoint-cache": {
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz",
+ "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mnemonist": "0.38.3",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/lib-dynamodb": {
+ "version": "3.1147.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1147.0.tgz",
+ "integrity": "sha512-232+7ND08nb0YZrCyCviYSpySiYDusCZo8mdovBo9eOHDqBvndiP7n5/CrtN5HjegGuwJYQ/0AedWu1ORVUGDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/util-dynamodb": "^3.996.9",
+ "@smithy/core": "^3.35.0",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "^3.1147.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-endpoint-discovery": {
+ "version": "3.972.31",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz",
+ "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/endpoint-cache": "^3.972.11",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
@@ -1809,13 +1503,15 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.997.46",
+ "version": "3.997.48",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.48.tgz",
+ "integrity": "sha512-B01jdXJtAW/XOiUzGdI2mz67WyEHbGEc9X6Euzw3bjLAa+txTqdZDER4X2FsIEcXzV2ABqudVZ5thali8tCCzQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/signature-v4-multi-region": "^3.996.47",
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.48",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -1841,11 +1537,13 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
- "version": "3.996.47",
+ "version": "3.996.48",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz",
+ "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
- "@smithy/signature-v4": "^5.7.3",
+ "@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1854,13 +1552,15 @@
}
},
"node_modules/@aws-sdk/token-providers": {
- "version": "3.1138.0",
+ "version": "3.1150.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1150.0.tgz",
+ "integrity": "sha512-wDU36ofhabZYu+d2KSriyDbwzxNoaOp+4dG0p3qkabk7HeALR5nIWyQbjoyJZLn3iqWkv/xNdyUfbfevDVDzzg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/core": "^3.978.3",
+ "@aws-sdk/nested-clients": "^3.997.48",
"@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
+ "@smithy/core": "^3.35.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -1879,6 +1579,21 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/util-dynamodb": {
+ "version": "3.996.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz",
+ "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "^3.1111.0"
+ }
+ },
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"license": "Apache-2.0",
@@ -2825,6 +2540,10 @@
"resolved": "internal/helpers",
"link": true
},
+ "node_modules/@internal/letter-sla-breaches-common": {
+ "resolved": "lambdas/letter-sla-breaches/common",
+ "link": true
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"license": "ISC",
@@ -4006,7 +3725,9 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.35.1",
+ "version": "3.36.0",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.0.tgz",
+ "integrity": "sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -4018,6 +3739,8 @@
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
@@ -12379,6 +12102,8 @@
},
"node_modules/mnemonist": {
"version": "0.38.3",
+ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz",
+ "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==",
"license": "MIT",
"dependencies": {
"obliterator": "^1.6.1"
@@ -12521,6 +12246,10 @@
"resolved": "lambdas/supplier-allocator",
"link": true
},
+ "node_modules/nhs-notify-supplier-api-eventpub-parquet-transformer": {
+ "resolved": "lambdas/eventpub-parquet-transformer",
+ "link": true
+ },
"node_modules/nhs-notify-supplier-api-handler": {
"resolved": "lambdas/api-handler",
"link": true
@@ -12537,6 +12266,18 @@
"resolved": "lambdas/mi-updates-transformer",
"link": true
},
+ "node_modules/nhs-notify-supplier-api-populate-sameday-breaches": {
+ "resolved": "lambdas/letter-sla-breaches/populate-sameday-breaches",
+ "link": true
+ },
+ "node_modules/nhs-notify-supplier-api-populate-standard-breaches": {
+ "resolved": "lambdas/letter-sla-breaches/populate-standard-breaches",
+ "link": true
+ },
+ "node_modules/nhs-notify-supplier-api-populate-supplier-config-cache": {
+ "resolved": "lambdas/letter-sla-breaches/populate-supplier-config-cache",
+ "link": true
+ },
"node_modules/nhs-notify-supplier-api-supplier-config-ingress": {
"resolved": "lambdas/supplier-config-ingress",
"link": true
@@ -12818,6 +12559,8 @@
},
"node_modules/obliterator": {
"version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz",
+ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
"license": "MIT"
},
"node_modules/obug": {
@@ -16760,30 +16503,6 @@
"node": ">=20.0.0"
}
},
- "scripts/utilities/letter-test-data/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "scripts/utilities/letter-test-data/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"scripts/utilities/letter-test-data/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -16801,33 +16520,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "scripts/utilities/letter-test-data/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "scripts/utilities/letter-test-data/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"scripts/utilities/letter-test-data/node_modules/ansi-regex": {
"version": "6.4.0",
"license": "MIT",
@@ -16989,30 +16681,6 @@
"node": ">=20.0.0"
}
},
- "scripts/utilities/supplier-data/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "scripts/utilities/supplier-data/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"scripts/utilities/supplier-data/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -17030,33 +16698,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "scripts/utilities/supplier-data/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "scripts/utilities/supplier-data/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"scripts/utilities/supplier-data/node_modules/ansi-regex": {
"version": "6.4.0",
"license": "MIT",
@@ -17274,30 +16915,6 @@
"node": ">=20.0.0"
}
},
- "tests/node_modules/@aws-sdk/dynamodb-codec": {
- "version": "3.973.46",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "tests/node_modules/@aws-sdk/endpoint-cache": {
- "version": "3.972.11",
- "license": "Apache-2.0",
- "dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"tests/node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -17315,20 +16932,6 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "tests/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/endpoint-cache": "^3.972.11",
- "@aws-sdk/types": "^3.974.6",
- "@smithy/core": "^3.35.0",
- "@smithy/types": "^4.19.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"tests/node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.43",
"license": "Apache-2.0",
@@ -17342,19 +16945,6 @@
"node": ">=20.0.0"
}
},
- "tests/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"tests/node_modules/@babel/code-frame": {
"version": "8.0.6",
"dev": true,
diff --git a/package.json b/package.json
index 4e7f0e70d..68a2c9b28 100644
--- a/package.json
+++ b/package.json
@@ -149,6 +149,7 @@
"docs",
"internal/*",
"lambdas/*",
+ "lambdas/letter-sla-breaches/*",
"pact-contracts",
"scripts/utilities/*",
"tests",