diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml
index f1282be1f..051e5564c 100644
--- a/.github/workflows/stage-2-test.yaml
+++ b/.github/workflows/stage-2-test.yaml
@@ -77,6 +77,14 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
+ # Pull testcontainers images in the background so the download overlaps
+ # the setup steps. Keep these references in sync with the images used in
+ # internal/datastore/src/__test__/db.ts and
+ # internal/sql-test-helpers/src/trino.ts.
+ - name: "Pre-pull test container images"
+ run: |
+ docker pull amazon/dynamodb-local > /dev/null 2>&1 &
+ docker pull trinodb/trino:476 > /dev/null 2>&1 &
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup NodeJS
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..89bcc7e84 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,13 @@ 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\_athena\_query\_results](#module\_s3bucket\_athena\_query\_results) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.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_letter_sla_breaches.tf b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf
new file mode 100644
index 000000000..e3939d786
--- /dev/null
+++ b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf
@@ -0,0 +1,19 @@
+resource "aws_athena_workgroup" "letter_sla_breaches" {
+ name = "${local.csi}-letter-sla-breaches"
+ description = "Athena workgroup for the letter SLA breaches state machine"
+ force_destroy = var.force_destroy
+
+ configuration {
+ enforce_workgroup_configuration = true
+ publish_cloudwatch_metrics_enabled = true
+
+ result_configuration {
+ output_location = "s3://${module.s3bucket_athena_query_results.bucket}/letter-sla-breaches/"
+
+ encryption_configuration {
+ encryption_option = "SSE_KMS"
+ kms_key_arn = module.kms.key_arn
+ }
+ }
+ }
+}
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..97a35bad2
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf
@@ -0,0 +1,17 @@
+resource "aws_iam_role" "letter_sla_breaches" {
+ 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..f8076c49a
--- /dev/null
+++ b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
@@ -0,0 +1,27 @@
+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.id
+ policy = data.aws_iam_policy_document.letter_sla_breaches_policy.json
+}
+
+data "aws_iam_policy_document" "letter_sla_breaches_policy" {
+ 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.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..24900a1f2 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"]
@@ -47,9 +77,16 @@ locals {
event_cache_bucket_name = lookup(module.eventpub.s3_bucket_event_cache, "bucket", null)
eventsub_event_cache_bucket_name = lookup(module.eventsub.s3_bucket_event_cache, "bucket", null)
+ reporting_bucket_name = lookup(module.s3bucket_reporting, "bucket", null)
+
+ # The Glue crawler names tables after the crawled bucket, replacing characters that are invalid in table names
+ event_cache_glue_table_name = local.event_cache_bucket_name != null ? lower(replace(local.event_cache_bucket_name, "/[^a-zA-Z0-9_]/", "_")) : null
csoc_api_gw_log_destination_arn = format("arn:aws:logs:%s:%s:destination:api_gateway_log_destination",
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..020c4dd1c
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf
@@ -0,0 +1,55 @@
+module "populate_sameday_breaches" {
+ 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 = {}
+}
+
+data "aws_iam_policy_document" "populate_sameday_breaches_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+}
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..b994f3af8
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
@@ -0,0 +1,131 @@
+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 = 300
+ 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.letter_sla_breaches.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 = "AllowAthenaQueries"
+ effect = "Allow"
+
+ actions = [
+ "athena:GetQueryExecution",
+ "athena:GetQueryResults",
+ "athena:StartQueryExecution",
+ "athena:StopQueryExecution",
+ ]
+
+ resources = [
+ aws_athena_workgroup.letter_sla_breaches.arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowGlueCatalogRead"
+ effect = "Allow"
+
+ actions = [
+ "glue:GetDatabase",
+ "glue:GetPartition",
+ "glue:GetPartitions",
+ "glue:GetTable",
+ "glue:GetTables",
+ ]
+
+ resources = [
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog",
+ aws_glue_catalog_database.supplier.arn,
+ "arn:aws:glue:${var.region}:${var.aws_account_id}:table/${aws_glue_catalog_database.supplier.name}/*",
+ ]
+ }
+
+ statement {
+ sid = "AllowReadReportingBucket"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetBucketLocation",
+ "s3:GetObject",
+ "s3:ListBucket",
+ ]
+
+ resources = [
+ "arn:aws:s3:::${local.reporting_bucket_name}",
+ "arn:aws:s3:::${local.reporting_bucket_name}/*",
+ ]
+ }
+
+ statement {
+ sid = "AllowAthenaQueryResultsBucket"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetBucketLocation",
+ "s3:GetObject",
+ "s3:ListBucket",
+ "s3:ListMultipartUploadParts",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ module.s3bucket_athena_query_results.arn,
+ "${module.s3bucket_athena_query_results.arn}/letter-sla-breaches/*",
+ ]
+ }
+}
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/s3_athena_query_results.tf b/infrastructure/terraform/components/api/s3_athena_query_results.tf
new file mode 100644
index 000000000..68d514b78
--- /dev/null
+++ b/infrastructure/terraform/components/api/s3_athena_query_results.tf
@@ -0,0 +1,73 @@
+module "s3bucket_athena_query_results" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip"
+
+ name = "athena-query-results"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = var.component
+
+ acl = "private"
+ force_destroy = var.force_destroy
+ versioning = false
+ kms_key_arn = module.kms.key_arn
+
+ lifecycle_rules = [
+ {
+ enabled = true
+
+ expiration = {
+ days = "7"
+ }
+
+ abort_incomplete_multipart_upload = {
+ days = "1"
+ }
+ }
+ ]
+
+ policy_documents = [
+ data.aws_iam_policy_document.s3bucket_athena_query_results.json
+ ]
+
+ bucket_logging_target = {
+ bucket = local.acct.s3_buckets["access_logs"]["id"]
+ }
+
+ public_access = {
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+ }
+
+ default_tags = {
+ Name = "Athena Query Results"
+ }
+}
+
+data "aws_iam_policy_document" "s3bucket_athena_query_results" {
+ statement {
+ sid = "DontAllowNonSecureConnection"
+ effect = "Deny"
+ actions = ["s3:*"]
+
+ resources = [
+ module.s3bucket_athena_query_results.arn,
+ "${module.s3bucket_athena_query_results.arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = ["*"]
+ }
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
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..e36a69e26
--- /dev/null
+++ b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
@@ -0,0 +1,13 @@
+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.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.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..0486ee22a 100644
--- a/infrastructure/terraform/components/api/variables.tf
+++ b/infrastructure/terraform/components/api/variables.tf
@@ -296,3 +296,15 @@ variable "event_source_filter" {
type = list(string)
description = "SNS subscription source filter values"
}
+
+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
+}
diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts
index 7e5f18edf..76c245175 100644
--- a/internal/datastore/src/__test__/db.ts
+++ b/internal/datastore/src/__test__/db.ts
@@ -9,6 +9,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DatastoreConfig } from "../config";
export async function setupDynamoDBContainer() {
+ // Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync.
const container = await new GenericContainer("amazon/dynamodb-local")
.withExposedPorts(8000)
.start();
diff --git a/internal/sql-test-helpers/README.md b/internal/sql-test-helpers/README.md
new file mode 100644
index 000000000..819b06b30
--- /dev/null
+++ b/internal/sql-test-helpers/README.md
@@ -0,0 +1,46 @@
+
+
+# @internal/sql-test-helpers
+
+## Purpose
+
+Test-only helpers for running SQL queries against databases in [testcontainers](https://node.testcontainers.org/), so that query builders can be checked against a real SQL engine using known test data.
+
+## General Structure highlights
+
+- **`src/trino.ts`**: Trino helpers, which talk to Trino over its REST API using `fetch`:
+ - `setupTrinoContainer()` — starts a `trinodb/trino` `GenericContainer` and returns a `TrinoContext` (`container`, `endpoint`).
+ - `runTrinoStatement(context, sql)` — runs a statement against the built-in `memory` catalog, follows result pagination and returns rows as `Record` (NULL → `undefined`).
+ - `runParameterisedQueryOnTrino(context, query)` — runs an Athena-style `{ queryString, executionParameters }` query via `EXECUTE IMMEDIATE ... USING`. Execution parameters are SQL literal expressions, as in Athena (for example `"'2026-10-14T12:00:00.000Z'"`).
+
+## Usage
+
+Add the package as a **devDependency** of the consuming workspace and import it from test code only:
+
+```json
+"devDependencies": {
+ "@internal/sql-test-helpers": "*"
+}
+```
+
+```ts
+import {
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "@internal/sql-test-helpers";
+
+const context = await setupTrinoContainer();
+await runTrinoStatement(context, "CREATE TABLE event_cache (...)");
+const rows = await runParameterisedQueryOnTrino(context, buildQuery(...));
+await context.container.stop();
+```
+
+## Nuances and Peculiarities
+
+- Must never be imported by production code. Because consumers list it under `devDependencies`, ESLint's `import-x/no-extraneous-dependencies` rule reports any import from non-test files.
+- Has no unit tests of its own and is excluded from coverage (see `sonar.coverage.exclusions` in `scripts/config/sonar-scanner.properties`). It is checked through the tests that use it.
+- Tests that use it require Docker. The first run pulls the Trino image (about 1 GB), so allow a generous `beforeAll` timeout (for example `180_000` ms). In CI, the image is pulled in the background at the start of the unit test job in `.github/workflows/stage-2-test.yaml`. If you change `TRINO_IMAGE`, update that step too.
+- Trino SQL is close to, but not identical to, Athena's engine version. The helpers check query logic, not every Athena-specific behaviour.
+
+
diff --git a/internal/sql-test-helpers/package.json b/internal/sql-test-helpers/package.json
new file mode 100644
index 000000000..ec64c9abe
--- /dev/null
+++ b/internal/sql-test-helpers/package.json
@@ -0,0 +1,17 @@
+{
+ "dependencies": {
+ "testcontainers": "^12.2.0"
+ },
+ "description": "Test-only helpers for running SQL queries against containerised databases",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "name": "@internal/sql-test-helpers",
+ "private": true,
+ "scripts": {
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "echo \"Test helpers package has no unit tests\"",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "0.1.0"
+}
diff --git a/internal/sql-test-helpers/src/index.ts b/internal/sql-test-helpers/src/index.ts
new file mode 100644
index 000000000..79ebbf71c
--- /dev/null
+++ b/internal/sql-test-helpers/src/index.ts
@@ -0,0 +1,8 @@
+export {
+ type SqlQuery,
+ type SqlRow,
+ type TrinoContext,
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "./trino";
diff --git a/internal/sql-test-helpers/src/trino.ts b/internal/sql-test-helpers/src/trino.ts
new file mode 100644
index 000000000..2040e6f3e
--- /dev/null
+++ b/internal/sql-test-helpers/src/trino.ts
@@ -0,0 +1,181 @@
+import { GenericContainer, StartedTestContainer, Wait } from "testcontainers";
+
+// Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync.
+const TRINO_IMAGE = "trinodb/trino:476";
+const TRINO_PORT = 8080;
+
+// Structurally compatible with Athena's query/row shapes, so Athena query
+// builders can be exercised against Trino without depending on them here.
+export type SqlQuery = {
+ queryString: string;
+ executionParameters?: string[];
+};
+
+export type SqlRow = Record;
+
+type TrinoResponse = {
+ columns?: { name: string }[];
+ data?: unknown[][];
+ nextUri?: string;
+ error?: { message: string };
+};
+
+export type TrinoContext = {
+ container: StartedTestContainer;
+ endpoint: string;
+ logs: string[];
+};
+
+const NODE_READY_TIMEOUT_MS = 60_000;
+const NODE_READY_POLL_MS = 500;
+
+const sleep = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+// Trino can report "No nodes available to run query" when the coordinator has
+// not (or no longer) registered a worker, e.g. just after "SERVER STARTED" or
+// on a loaded CI runner. It is raised before execution starts, so retrying is
+// safe even for non-idempotent statements.
+const isNoNodesError = (error: unknown) =>
+ error instanceof Error && error.message.includes("No nodes available");
+
+export async function setupTrinoContainer(): Promise {
+ // Buffer logs from the start so they are available even if startup fails.
+ const logs: string[] = [];
+ let container: StartedTestContainer;
+
+ try {
+ container = await new GenericContainer(TRINO_IMAGE)
+ .withExposedPorts(TRINO_PORT)
+ .withWaitStrategy(Wait.forLogMessage("======== SERVER STARTED ========"))
+ .withStartupTimeout(120_000)
+ .withLogConsumer((stream) => {
+ stream.on("data", (chunk) => logs.push(chunk.toString()));
+ })
+ .start();
+ } catch (error) {
+ printLogs(logs);
+ throw error;
+ }
+
+ const endpoint = `http://${container.getHost()}:${container.getMappedPort(TRINO_PORT)}`;
+ const context = { container, endpoint, logs };
+
+ try {
+ await runTrinoStatement(context, "SELECT 1");
+ } catch (error) {
+ printLogs(logs);
+ await container.stop();
+ throw error;
+ }
+
+ return context;
+}
+
+function printLogs(logs: string[]) {
+ // eslint-disable-next-line no-console
+ console.error(`Trino container logs:\n${logs.join("")}`);
+}
+
+async function readTrinoResponse(response: Response): Promise {
+ if (!response.ok) {
+ throw new Error(
+ `Trino request failed: ${response.status} ${await response.text()}`,
+ );
+ }
+
+ const body = (await response.json()) as TrinoResponse;
+
+ if (body.error) {
+ throw new Error(`Trino query failed: ${body.error.message}`);
+ }
+
+ return body;
+}
+
+function toRow(columns: string[], values: unknown[]): SqlRow {
+ return Object.fromEntries(
+ columns.map((name, i) => {
+ const value = values.at(i);
+ return [name, value === null ? undefined : String(value)];
+ }),
+ );
+}
+
+// Runs a statement via the Trino REST API against the in-memory catalog.
+export async function runTrinoStatement(
+ context: TrinoContext,
+ sql: string,
+): Promise {
+ const deadline = Date.now() + NODE_READY_TIMEOUT_MS;
+
+ for (;;) {
+ try {
+ return await executeTrinoStatement(context, sql);
+ } catch (error) {
+ if (!isNoNodesError(error) || Date.now() >= deadline) {
+ printLogs(context.logs);
+ throw error;
+ }
+ }
+
+ await sleep(NODE_READY_POLL_MS);
+ }
+}
+
+async function executeTrinoStatement(
+ context: TrinoContext,
+ sql: string,
+): Promise {
+ let body = await readTrinoResponse(
+ await fetch(`${context.endpoint}/v1/statement`, {
+ method: "POST",
+ headers: {
+ "X-Trino-User": "test",
+ "X-Trino-Catalog": "memory",
+ "X-Trino-Schema": "default",
+ },
+ body: sql,
+ }),
+ );
+
+ let columns: string[] = [];
+ const rows: SqlRow[] = [];
+
+ while (true) {
+ if (body.columns) {
+ columns = body.columns.map((column) => column.name);
+ }
+
+ const currentColumns = columns;
+ rows.push(
+ ...(body.data ?? []).map((values) => toRow(currentColumns, values)),
+ );
+
+ if (!body.nextUri) {
+ return rows;
+ }
+
+ body = await readTrinoResponse(
+ await fetch(body.nextUri, { headers: { "X-Trino-User": "test" } }),
+ );
+ }
+}
+
+// Emulates Athena parameterised queries: each execution parameter is
+// substituted into the query as a SQL literal expression.
+export async function runParameterisedQueryOnTrino(
+ context: TrinoContext,
+ query: SqlQuery,
+): Promise {
+ const escapedQuery = query.queryString.replaceAll("'", "''");
+ const parameters = query.executionParameters ?? [];
+ const using = parameters.length > 0 ? ` USING ${parameters.join(", ")}` : "";
+
+ return runTrinoStatement(
+ context,
+ `EXECUTE IMMEDIATE '${escapedQuery}'${using}`,
+ );
+}
diff --git a/internal/sql-test-helpers/tsconfig.json b/internal/sql-test-helpers/tsconfig.json
new file mode 100644
index 000000000..70ae3804b
--- /dev/null
+++ b/internal/sql-test-helpers/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {},
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "src/**/*"
+ ]
+}
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..677cf5752
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts
@@ -0,0 +1,92 @@
+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: "g1",
+ campaignId: "c1",
+ clientId: "cl1",
+ 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: "g1",
+ campaignid: "c1",
+ clientid: "cl1",
+ status: "PRINTED",
+ });
+ });
+
+ 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..b743de50f
--- /dev/null
+++ b/lambdas/eventpub-parquet-transformer/src/transform-record.ts
@@ -0,0 +1,83 @@
+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",
+ "campaignId",
+ "clientId",
+ "status",
+] as const;
+
+type Json = Record;
+
+function isObject(value: unknown): value is Json {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+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];
+ }
+ 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..d33a405a0
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
@@ -0,0 +1,155 @@
+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'"] };
+
+const deps = {
+ athenaClient: { send: jest.fn() } 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;
+
+const columnInfo = { ColumnInfo: [{ Name: "id" }, { Name: "status" }] };
+
+function mockResolvedValues(fn: any, returnValues: any[]) {
+ for (const value of returnValues) {
+ (fn as jest.Mock).mockResolvedValueOnce(value);
+ }
+}
+
+describe("runAthenaQuery", () => {
+ it("starts the query, polls until complete and returns paginated rows", async () => {
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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 = (deps.athenaClient.send as jest.Mock).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 () => {
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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 () => {
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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();
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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 () => {
+ mockResolvedValues(deps.athenaClient.send, [{}]);
+
+ 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) => {
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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 () => {
+ mockResolvedValues(deps.athenaClient.send, [
+ { 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..9bd2d58fb
--- /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 {
+ while (true) {
+ 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({ description: "Started Athena query", 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..27aaea112
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/index.ts
@@ -0,0 +1,5 @@
+export { default as runAthenaQuery } from "./athena";
+export { default as createDependenciesContainer } from "./deps";
+export { default as buildAthenaDatePredicate } from "./athena-date-predicate";
+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..127c212c3
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json
@@ -0,0 +1,15 @@
+{
+ "dependencies": {
+ "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..50492a04a
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts
@@ -0,0 +1,12 @@
+import populateSamedayBreaches from "../handler/populate-sameday-breaches";
+
+describe("populateSamedayBreaches", () => {
+ it("returns a success result", async () => {
+ const result = await populateSamedayBreaches({ date: "2026-10-08" });
+
+ expect(result).toEqual({
+ status: "SUCCESS",
+ message: "populate-sameday-breaches invoked successfully",
+ });
+ });
+});
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..f63d70492
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts
@@ -0,0 +1,18 @@
+export type PopulateSamedayBreachesEvent = {
+ date: string;
+};
+
+export type PopulateSamedayBreachesResult = {
+ status: "SUCCESS";
+ message: string;
+};
+
+// Placeholder: cache population is not yet implemented.
+export default async function populateSamedayBreaches(
+ _event: PopulateSamedayBreachesEvent,
+): Promise {
+ return {
+ status: "SUCCESS",
+ message: "populate-sameday-breaches invoked successfully",
+ };
+}
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..3143567ed
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import-x/prefer-default-export
+export { default as populateSamedayBreachesHandler } from "./handler/populate-sameday-breaches";
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..e92bbdc61
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
@@ -0,0 +1,19 @@
+{
+ "dependencies": {
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
+ },
+ "devDependencies": {
+ "@internal/sql-test-helpers": "*"
+ },
+ "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..d331ed329
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
@@ -0,0 +1,56 @@
+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",
+ breachCounts: JSON.stringify([{ breach_count: "1" }]),
+ });
+ });
+
+ 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",
+ breachCounts: "-",
+ });
+ });
+
+ 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",
+ breachCounts: "-",
+ });
+ });
+});
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..e4af0d4f1
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts
@@ -0,0 +1,204 @@
+import {
+ TrinoContext,
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "@internal/sql-test-helpers";
+import buildStandardBreachesQuery from "../handler/standard-breaches-query";
+
+type LetterEvent = {
+ domainid: string;
+ supplierid: string;
+ status: string;
+ time: string;
+};
+
+const TABLE_NAME = "event_cache";
+
+async function insertEvents(context: TrinoContext, events: LetterEvent[]) {
+ const values = events
+ .map(({ domainid, status, supplierid, time }) => {
+ const [year, month, day] = time.slice(0, 10).split("-");
+ return `('${domainid}', '${supplierid}', '${status}', '${time}', '${year}', '${month}', '${day}')`;
+ })
+ .join(",\n");
+
+ await runTrinoStatement(
+ context,
+ `INSERT INTO ${TABLE_NAME} VALUES ${values}`,
+ );
+}
+
+function sortBySupplier(rows: Record[]) {
+ return rows.toSorted((a, b) =>
+ (a.supplierid ?? "").localeCompare(b.supplierid ?? ""),
+ );
+}
+
+describe("buildStandardBreachesQuery", () => {
+ let context: TrinoContext;
+
+ beforeAll(async () => {
+ context = await setupTrinoContainer();
+ }, 180_000);
+
+ afterAll(async () => {
+ await context?.container.stop();
+ });
+
+ beforeEach(async () => {
+ await runTrinoStatement(
+ context,
+ `CREATE TABLE ${TABLE_NAME} (
+ domainid VARCHAR,
+ supplierid VARCHAR,
+ status VARCHAR,
+ time VARCHAR,
+ __year VARCHAR,
+ __month VARCHAR,
+ __day VARCHAR
+ )`,
+ );
+ });
+
+ afterEach(async () => {
+ await runTrinoStatement(context, `DROP TABLE IF EXISTS ${TABLE_NAME}`);
+ });
+
+ it("counts letters pending for more than 48 hours without a completed status, per supplier", async () => {
+ // Wednesday
+ const now = new Date("2026-10-14T12:00:00.000Z");
+
+ await insertEvents(context, [
+ // Breach: pending for > 48 hours, never completed
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ // Not a breach: completed
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ status: "DISPATCHED",
+ time: "2026-10-13T10:00:00.000Z",
+ },
+ // Not a breach: pending for < 48 hours
+ {
+ domainid: "letter-3",
+ supplierid: "supplier-a",
+ status: "PENDING",
+ time: "2026-10-13T10:00:00.000Z",
+ },
+ // Breach: a non-terminal status does not complete the letter
+ {
+ domainid: "letter-4",
+ supplierid: "supplier-b",
+ status: "PENDING",
+ time: "2026-10-10T09:00:00.000Z",
+ },
+ {
+ domainid: "letter-4",
+ supplierid: "supplier-b",
+ status: "ACCEPTED",
+ time: "2026-10-10T10:00:00.000Z",
+ },
+ // Breach: first PENDING event is used, even if repeated later
+ {
+ domainid: "letter-5",
+ supplierid: "supplier-b",
+ status: "PENDING",
+ time: "2026-10-12T11:00:00.000Z",
+ },
+ {
+ domainid: "letter-5",
+ supplierid: "supplier-b",
+ status: "PENDING",
+ time: "2026-10-14T11:00:00.000Z",
+ },
+ // Not a breach: each terminal status completes the letter
+ ...["REJECTED", "FAILED", "CANCELLED", "FORWARDED"].flatMap(
+ (status, i) => [
+ {
+ domainid: `letter-terminal-${i}`,
+ supplierid: "supplier-b",
+ status: "PENDING",
+ time: "2026-10-10T10:00:00.000Z",
+ },
+ {
+ domainid: `letter-terminal-${i}`,
+ supplierid: "supplier-b",
+ status,
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ ],
+ ),
+ // Breaching, but ignored: before the 14-day window
+ {
+ domainid: "letter-6",
+ supplierid: "supplier-c",
+ status: "PENDING",
+ time: "2026-09-01T10:00:00.000Z",
+ },
+ ]);
+
+ const rows = await runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(TABLE_NAME, now, 14),
+ );
+
+ expect(sortBySupplier(rows)).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ { supplierid: "supplier-b", breach_count: "2" },
+ ]);
+ });
+
+ it("skips non-working days", async () => {
+ await insertEvents(context, [
+ // Received Thursday - won't breach until Monday
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ status: "PENDING",
+ time: "2026-10-15T10:00:00.000Z",
+ },
+ // Received Friday - won't breach until Tuesday
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ status: "PENDING",
+ time: "2026-10-16T10:00:00.000Z",
+ },
+ ]);
+
+ const [rowsOnSaturday, rowsOnSunday, rowsOnMonday, rowsOnTuesday] =
+ await Promise.all(
+ [
+ new Date("2026-10-17T22:00:00.000Z"),
+ new Date("2026-10-18T22:00:00.000Z"),
+ new Date("2026-10-19T22:00:00.000Z"),
+ new Date("2026-10-20T22:00:00.000Z"),
+ ].map(async (day) =>
+ runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(TABLE_NAME, day, 14),
+ ),
+ ),
+ );
+ expect(rowsOnSaturday).toHaveLength(0);
+ expect(rowsOnSunday).toHaveLength(0);
+ expect(rowsOnMonday).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ ]);
+ expect(rowsOnTuesday).toEqual([
+ { supplierid: "supplier-a", breach_count: "2" },
+ ]);
+ });
+});
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..5ffdf4e20
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
@@ -0,0 +1,50 @@
+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;
+ breachCounts: string;
+};
+
+export default function createHandler(deps: Deps) {
+ return async (
+ event: PopulateStandardBreachesEvent,
+ ): Promise => {
+ try {
+ const sql = buildStandardBreachesQuery(
+ deps.env.EVENT_CACHE_TABLE_NAME,
+ new Date(event.date),
+ 14,
+ );
+ const rows = await runAthenaQuery(deps, sql);
+
+ // TODO: CCM-24041 process the query results to populate standard breaches.
+ deps.logger.info({
+ description: "Standard breaches query completed",
+ date: event.date,
+ breachCounts: JSON.stringify(rows),
+ });
+
+ return { status: "SUCCESS", breachCounts: JSON.stringify(rows) };
+ } catch (error) {
+ deps.logger.error({
+ description: "Failed to populate standard breaches",
+ date: event.date,
+ error,
+ });
+ return {
+ status: "FAILURE",
+ failureReason: error instanceof Error ? error.message : String(error),
+ breachCounts: "-",
+ };
+ }
+ };
+}
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..79c6aa462
--- /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 parameters AS (
+ SELECT from_iso8601_timestamp(?) AS now
+),
+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 now FROM parameters) -
+ CASE WHEN day_of_week(min(event_time) AT TIME ZONE 'Europe/London') IN (4, 5, 6) 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..9037c20bf 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.23",
"license": "MIT",
"dependencies": {
"@asyncapi/bundler": "^1.0.2",
@@ -307,6 +257,14 @@
"zod": "^4.6.5"
}
},
+ "internal/sql-test-helpers": {
+ "name": "@internal/sql-test-helpers",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "testcontainers": "^12.2.0"
+ }
+ },
"lambdas/api-handler": {
"name": "nhs-notify-supplier-api-handler",
"version": "0.0.1",
@@ -365,30 +323,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 +340,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 +353,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 +387,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,132 +404,120 @@
"@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
- "lambdas/authorizer/node_modules/@aws-sdk/middleware-endpoint-discovery": {
- "version": "3.972.31",
- "license": "Apache-2.0",
+ "lambdas/eventpub-parquet-transformer": {
+ "name": "nhs-notify-supplier-api-eventpub-parquet-transformer",
+ "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"
+ "aws-lambda": "^1.0.7",
+ "esbuild": "0.28.2"
}
},
- "lambdas/authorizer/node_modules/@aws-sdk/util-dynamodb": {
- "version": "3.996.9",
- "license": "Apache-2.0",
+ "lambdas/letter-sla-breaches/common": {
+ "name": "@internal/letter-sla-breaches-common",
+ "version": "0.0.1",
+ "license": "MIT",
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
+ "@aws-sdk/client-athena": "^3.1149.0",
+ "@internal/helpers": "^0.1.0",
+ "pino": "^10.4.0"
+ }
+ },
+ "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": {
+ "@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"
},
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
+ "bin": {
+ "pino": "bin.js"
}
},
- "lambdas/letter-updates-transformer": {
- "name": "nhs-notify-supplier-api-letter-updates-transformer",
- "version": "0.0.1",
+ "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/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"
+ "split2": "^4.0.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/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": {
- "@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"
+ "real-require": "^1.0.0"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=20"
}
},
- "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/populate-sameday-breaches": {
+ "name": "nhs-notify-supplier-api-populate-sameday-breaches",
+ "version": "0.0.1",
"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"
+ "esbuild": "0.28.2"
}
},
- "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/populate-standard-breaches": {
+ "name": "nhs-notify-supplier-api-populate-standard-breaches",
+ "version": "0.0.1",
"dependencies": {
- "mnemonist": "0.38.3",
- "tslib": "^2.6.2"
+ "@internal/letter-sla-breaches-common": "*",
+ "esbuild": "0.28.2"
},
- "engines": {
- "node": ">=20.0.0"
+ "devDependencies": {
+ "@internal/sql-test-helpers": "*"
}
},
- "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-supplier-config-cache": {
+ "name": "nhs-notify-supplier-api-populate-supplier-config-cache",
+ "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"
+ "@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"
}
},
- "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-updates-transformer": {
+ "name": "nhs-notify-supplier-api-letter-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/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": {
@@ -665,90 +536,6 @@
"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/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",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
- }
- },
"lambdas/supplier-allocator": {
"name": "nhs-notify-supplier-api-allocate-letter",
"version": "0.0.1",
@@ -856,30 +643,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 +660,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,26 +673,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",
- "engines": {
- "node": ">=18.0.0"
- }
- },
"lambdas/supplier-allocator/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": {
"version": "1.1.1",
"dependencies": {
@@ -1014,30 +743,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",
@@ -1045,41 +750,14 @@
"@aws-sdk/core": "^3.978.1",
"@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.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": {
+ "@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
- "@aws-sdk/client-dynamodb": "^3.1111.0"
+ "@aws-sdk/client-dynamodb": "^3.1145.0"
}
},
"lambdas/supplier-config-ingress/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": {
@@ -1167,30 +845,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 +862,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 +947,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,40 +964,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",
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3",
"license": "MIT",
@@ -1518,6 +1087,25 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/client-athena": {
+ "version": "3.1149.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-athena/-/client-athena-3.1149.0.tgz",
+ "integrity": "sha512-GPHrphuEDhybsEkcCOnmadtIkCAzJ6wckZ1gllkHhkicO5EccXwMHeZNsmTWyjkrnafD4EjSz5Oaf+HfbHLIZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.2",
+ "@aws-sdk/credential-provider-node": "^3.972.85",
+ "@aws-sdk/types": "^3.974.6",
+ "@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"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/client-cloudwatch-logs": {
"version": "3.1146.0",
"dev": true,
@@ -1536,6 +1124,27 @@
"node": ">=20.0.0"
}
},
+ "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",
+ "@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-kinesis": {
"version": "3.1145.0",
"license": "Apache-2.0",
@@ -1625,14 +1234,16 @@
}
},
"node_modules/@aws-sdk/core": {
- "version": "3.978.1",
+ "version": "3.978.2",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.2.tgz",
+ "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==",
"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"
@@ -1641,20 +1252,15 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": {
- "version": "0.3.0",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.72",
+ "version": "3.972.73",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.73.tgz",
+ "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.2",
"@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 +1269,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.74",
+ "version": "3.972.75",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.75.tgz",
+ "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.2",
"@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 +1287,21 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.973.17",
+ "version": "3.973.18",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.18.tgz",
+ "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==",
"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.2",
+ "@aws-sdk/credential-provider-env": "^3.972.73",
+ "@aws-sdk/credential-provider-http": "^3.972.75",
+ "@aws-sdk/credential-provider-login": "^3.972.80",
+ "@aws-sdk/credential-provider-process": "^3.972.73",
+ "@aws-sdk/credential-provider-sso": "^3.973.17",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.79",
+ "@aws-sdk/nested-clients": "^3.997.47",
"@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 +1311,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.79",
+ "version": "3.972.80",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.80.tgz",
+ "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/core": "^3.978.2",
+ "@aws-sdk/nested-clients": "^3.997.47",
"@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 +1328,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.84",
+ "version": "3.972.85",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.85.tgz",
+ "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==",
"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.73",
+ "@aws-sdk/credential-provider-http": "^3.972.75",
+ "@aws-sdk/credential-provider-ini": "^3.973.18",
+ "@aws-sdk/credential-provider-process": "^3.972.73",
+ "@aws-sdk/credential-provider-sso": "^3.973.17",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.79",
"@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 +1350,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.72",
+ "version": "3.972.73",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.73.tgz",
+ "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/core": "^3.978.2",
"@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 +1366,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.973.16",
+ "version": "3.973.17",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.17.tgz",
+ "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==",
"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.2",
+ "@aws-sdk/nested-clients": "^3.997.47",
+ "@aws-sdk/token-providers": "3.1149.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 +1384,76 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.78",
+ "version": "3.972.79",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.79.tgz",
+ "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.2",
+ "@aws-sdk/nested-clients": "^3.997.47",
+ "@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 +1492,15 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.997.46",
+ "version": "3.997.47",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.47.tgz",
+ "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==",
"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.2",
+ "@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 +1526,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 +1541,15 @@
}
},
"node_modules/@aws-sdk/token-providers": {
- "version": "3.1138.0",
+ "version": "3.1149.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1149.0.tgz",
+ "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.978.1",
- "@aws-sdk/nested-clients": "^3.997.46",
+ "@aws-sdk/core": "^3.978.2",
+ "@aws-sdk/nested-clients": "^3.997.47",
"@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 +1568,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",
@@ -1890,6 +1594,15 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"license": "MIT",
@@ -2279,7 +1992,6 @@
},
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
@@ -2649,8 +2361,9 @@
}
},
"node_modules/@grpc/grpc-js": {
- "version": "1.14.4",
- "dev": true,
+ "version": "1.14.6",
+ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz",
+ "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
@@ -2662,7 +2375,6 @@
},
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
"version": "0.8.1",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
@@ -2679,7 +2391,6 @@
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
@@ -2825,6 +2536,14 @@
"resolved": "internal/helpers",
"link": true
},
+ "node_modules/@internal/letter-sla-breaches-common": {
+ "resolved": "lambdas/letter-sla-breaches/common",
+ "link": true
+ },
+ "node_modules/@internal/sql-test-helpers": {
+ "resolved": "internal/sql-test-helpers",
+ "link": true
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"license": "ISC",
@@ -3309,7 +3028,6 @@
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
- "dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -3355,7 +3073,6 @@
},
"node_modules/@kwsites/file-exists": {
"version": "1.1.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1"
@@ -3809,27 +3526,22 @@
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
- "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
@@ -3837,22 +3549,18 @@
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@redocly/ajv": {
@@ -4006,7 +3714,9 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.35.1",
+ "version": "3.35.2",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.2.tgz",
+ "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -4643,7 +4353,6 @@
},
"node_modules/@types/docker-modem": {
"version": "3.0.6",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -4652,7 +4361,6 @@
},
"node_modules/@types/dockerode": {
"version": "4.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/docker-modem": "*",
@@ -4760,7 +4468,6 @@
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18"
@@ -4768,7 +4475,6 @@
},
"node_modules/@types/ssh2-streams": {
"version": "0.1.13",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4776,7 +4482,6 @@
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -4784,7 +4489,6 @@
},
"node_modules/@types/ssh2/node_modules/undici-types": {
"version": "5.26.5",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": {
@@ -5088,7 +4792,6 @@
},
"node_modules/abort-controller": {
"version": "3.0.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
@@ -5313,7 +5016,6 @@
},
"node_modules/archiver": {
"version": "7.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.2",
@@ -5330,7 +5032,6 @@
},
"node_modules/archiver-utils": {
"version": "5.0.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"glob": "^10.0.0",
@@ -5347,12 +5048,10 @@
},
"node_modules/archiver-utils/node_modules/balanced-match": {
"version": "1.0.2",
- "dev": true,
"license": "MIT"
},
"node_modules/archiver-utils/node_modules/brace-expansion": {
"version": "2.1.7",
- "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -5360,7 +5059,6 @@
},
"node_modules/archiver-utils/node_modules/buffer": {
"version": "6.0.3",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5383,7 +5081,6 @@
},
"node_modules/archiver-utils/node_modules/events": {
"version": "3.3.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -5391,7 +5088,6 @@
},
"node_modules/archiver-utils/node_modules/glob": {
"version": "10.5.0",
- "dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -5410,7 +5106,6 @@
},
"node_modules/archiver-utils/node_modules/ieee754": {
"version": "1.2.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5429,12 +5124,10 @@
},
"node_modules/archiver-utils/node_modules/lru-cache": {
"version": "10.4.3",
- "dev": true,
"license": "ISC"
},
"node_modules/archiver-utils/node_modules/minimatch": {
"version": "9.0.9",
- "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
@@ -5448,7 +5141,6 @@
},
"node_modules/archiver-utils/node_modules/path-scurry": {
"version": "1.11.1",
- "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -5463,7 +5155,6 @@
},
"node_modules/archiver-utils/node_modules/readable-stream": {
"version": "4.7.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -5478,7 +5169,6 @@
},
"node_modules/archiver/node_modules/buffer": {
"version": "6.0.3",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5501,7 +5191,6 @@
},
"node_modules/archiver/node_modules/events": {
"version": "3.3.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -5509,7 +5198,6 @@
},
"node_modules/archiver/node_modules/ieee754": {
"version": "1.2.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5528,7 +5216,6 @@
},
"node_modules/archiver/node_modules/readable-stream": {
"version": "4.7.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -5716,7 +5403,6 @@
},
"node_modules/asn1": {
"version": "0.2.6",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
@@ -5748,7 +5434,6 @@
},
"node_modules/async": {
"version": "3.2.6",
- "dev": true,
"license": "MIT"
},
"node_modules/async-function": {
@@ -5761,7 +5446,6 @@
},
"node_modules/async-lock": {
"version": "1.4.1",
- "dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
@@ -5902,7 +5586,6 @@
},
"node_modules/b4a": {
"version": "1.8.1",
- "dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
@@ -6010,7 +5693,6 @@
},
"node_modules/bare-events": {
"version": "2.9.1",
- "dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
@@ -6023,7 +5705,6 @@
},
"node_modules/bare-fs": {
"version": "4.7.2",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
@@ -6046,7 +5727,6 @@
},
"node_modules/bare-os": {
"version": "3.9.1",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
@@ -6054,7 +5734,6 @@
},
"node_modules/bare-path": {
"version": "3.0.1",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
@@ -6062,7 +5741,6 @@
},
"node_modules/bare-stream": {
"version": "2.13.1",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.25.0",
@@ -6087,7 +5765,6 @@
},
"node_modules/bare-url": {
"version": "2.4.5",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
@@ -6131,7 +5808,6 @@
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
- "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
@@ -6139,7 +5815,6 @@
},
"node_modules/bl": {
"version": "4.1.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
@@ -6149,7 +5824,6 @@
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -6416,7 +6090,6 @@
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
@@ -6424,7 +6097,6 @@
},
"node_modules/buildcheck": {
"version": "0.0.7",
- "dev": true,
"optional": true,
"engines": {
"node": ">=10.0.0"
@@ -6448,7 +6120,6 @@
},
"node_modules/byline": {
"version": "5.0.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6630,7 +6301,6 @@
},
"node_modules/chownr": {
"version": "1.1.4",
- "dev": true,
"license": "ISC"
},
"node_modules/ci-info": {
@@ -6822,7 +6492,6 @@
},
"node_modules/compress-commons": {
"version": "6.0.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
@@ -6837,7 +6506,6 @@
},
"node_modules/compress-commons/node_modules/buffer": {
"version": "6.0.3",
- "dev": true,
"funding": [
{
"type": "github",
@@ -6860,7 +6528,6 @@
},
"node_modules/compress-commons/node_modules/events": {
"version": "3.3.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -6868,7 +6535,6 @@
},
"node_modules/compress-commons/node_modules/ieee754": {
"version": "1.2.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -6887,7 +6553,6 @@
},
"node_modules/compress-commons/node_modules/readable-stream": {
"version": "4.7.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -7175,12 +6840,10 @@
},
"node_modules/core-util-is": {
"version": "1.0.3",
- "dev": true,
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
- "dev": true,
"hasInstallScript": true,
"optional": true,
"dependencies": {
@@ -7193,7 +6856,6 @@
},
"node_modules/crc-32": {
"version": "1.2.2",
- "dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
@@ -7204,7 +6866,6 @@
},
"node_modules/crc32-stream": {
"version": "6.0.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
@@ -7216,7 +6877,6 @@
},
"node_modules/crc32-stream/node_modules/buffer": {
"version": "6.0.3",
- "dev": true,
"funding": [
{
"type": "github",
@@ -7239,7 +6899,6 @@
},
"node_modules/crc32-stream/node_modules/events": {
"version": "3.3.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -7247,7 +6906,6 @@
},
"node_modules/crc32-stream/node_modules/ieee754": {
"version": "1.2.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -7266,7 +6924,6 @@
},
"node_modules/crc32-stream/node_modules/readable-stream": {
"version": "4.7.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -7593,7 +7250,6 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.5.0.tgz",
"integrity": "sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.2.2"
@@ -7604,7 +7260,6 @@
},
"node_modules/docker-modem": {
"version": "5.0.7",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
@@ -7618,7 +7273,6 @@
},
"node_modules/dockerode": {
"version": "5.0.1",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
@@ -7634,7 +7288,6 @@
},
"node_modules/dockerode/node_modules/tar-fs": {
"version": "2.1.5",
- "dev": true,
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
@@ -7645,7 +7298,6 @@
},
"node_modules/dockerode/node_modules/tar-stream": {
"version": "2.2.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
@@ -9262,7 +8914,6 @@
},
"node_modules/event-target-shim": {
"version": "5.0.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -9281,7 +8932,6 @@
},
"node_modules/events-universal": {
"version": "1.0.1",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
@@ -9401,7 +9051,6 @@
},
"node_modules/fast-fifo": {
"version": "1.3.2",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -9685,7 +9334,6 @@
},
"node_modules/fs-constants": {
"version": "1.0.0",
- "dev": true,
"license": "MIT"
},
"node_modules/fs-extra": {
@@ -9807,7 +9455,6 @@
},
"node_modules/get-port": {
"version": "5.1.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10776,7 +10423,6 @@
},
"node_modules/is-stream": {
"version": "2.0.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11860,7 +11506,6 @@
},
"node_modules/lazystream": {
"version": "1.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
@@ -11871,7 +11516,6 @@
},
"node_modules/lazystream/node_modules/readable-stream": {
"version": "2.3.8",
- "dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
@@ -11885,12 +11529,10 @@
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
- "dev": true,
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
@@ -12126,7 +11768,6 @@
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
- "dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -12156,7 +11797,6 @@
},
"node_modules/long": {
"version": "5.3.2",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
@@ -12325,7 +11965,6 @@
},
"node_modules/minimatch": {
"version": "5.1.9",
- "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -12336,12 +11975,10 @@
},
"node_modules/minimatch/node_modules/balanced-match": {
"version": "1.0.2",
- "dev": true,
"license": "MIT"
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "2.1.7",
- "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12374,11 +12011,12 @@
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
- "dev": true,
"license": "MIT"
},
"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"
@@ -12466,7 +12104,6 @@
},
"node_modules/nan": {
"version": "2.27.0",
- "dev": true,
"license": "MIT",
"optional": true
},
@@ -12521,6 +12158,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 +12178,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 +12471,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": {
@@ -13498,7 +13153,6 @@
},
"node_modules/process": {
"version": "0.11.10",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
@@ -13506,7 +13160,6 @@
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
- "dev": true,
"license": "MIT"
},
"node_modules/process-warning": {
@@ -13535,7 +13188,6 @@
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -13545,12 +13197,10 @@
},
"node_modules/proper-lockfile/node_modules/signal-exit": {
"version": "3.0.7",
- "dev": true,
"license": "ISC"
},
"node_modules/properties-reader": {
"version": "3.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
@@ -13566,7 +13216,6 @@
},
"node_modules/properties-reader/node_modules/mkdirp": {
"version": "3.0.1",
- "dev": true,
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
@@ -13580,7 +13229,6 @@
},
"node_modules/protobufjs": {
"version": "7.6.5",
- "dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -13819,7 +13467,6 @@
},
"node_modules/readable-stream": {
"version": "3.6.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -13832,7 +13479,6 @@
},
"node_modules/readdir-glob": {
"version": "1.1.3",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
@@ -14003,7 +13649,6 @@
},
"node_modules/require-directory": {
"version": "2.1.1",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14082,7 +13727,6 @@
},
"node_modules/retry": {
"version": "0.12.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@@ -14710,7 +14354,6 @@
},
"node_modules/split-ca": {
"version": "1.0.1",
- "dev": true,
"license": "ISC"
},
"node_modules/split2": {
@@ -14726,7 +14369,6 @@
},
"node_modules/ssh-remote-port-forward": {
"version": "1.0.4",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/ssh2": "^0.5.48",
@@ -14735,7 +14377,6 @@
},
"node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": {
"version": "0.5.52",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -14744,7 +14385,6 @@
},
"node_modules/ssh2": {
"version": "1.17.0",
- "dev": true,
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
@@ -14818,7 +14458,6 @@
},
"node_modules/streamx": {
"version": "2.27.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
@@ -14828,7 +14467,6 @@
},
"node_modules/string_decoder": {
"version": "1.3.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
@@ -15108,7 +14746,6 @@
},
"node_modules/tar-fs": {
"version": "3.1.3",
- "dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
@@ -15121,7 +14758,6 @@
},
"node_modules/tar-stream": {
"version": "3.2.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
@@ -15132,7 +14768,6 @@
},
"node_modules/teex": {
"version": "1.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
@@ -15244,7 +14879,6 @@
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.2.0.tgz",
"integrity": "sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
@@ -15269,7 +14903,6 @@
},
"node_modules/text-decoder": {
"version": "1.2.7",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
@@ -15347,7 +14980,6 @@
},
"node_modules/tmp": {
"version": "0.2.7",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -15650,7 +15282,6 @@
},
"node_modules/tweetnacl": {
"version": "0.14.5",
- "dev": true,
"license": "Unlicense"
},
"node_modules/type-check": {
@@ -16046,7 +15677,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
- "dev": true,
"license": "MIT"
},
"node_modules/utility-types": {
@@ -16524,7 +16154,6 @@
},
"node_modules/yaml": {
"version": "2.9.1",
- "dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -16543,7 +16172,6 @@
},
"node_modules/yargs": {
"version": "17.7.2",
- "dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
@@ -16560,7 +16188,6 @@
},
"node_modules/yargs-parser": {
"version": "21.1.1",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@@ -16568,7 +16195,6 @@
},
"node_modules/yargs/node_modules/cliui": {
"version": "8.0.1",
- "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -16581,7 +16207,6 @@
},
"node_modules/yargs/node_modules/wrap-ansi": {
"version": "7.0.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -16616,7 +16241,6 @@
},
"node_modules/zip-stream": {
"version": "6.0.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.0",
@@ -16629,7 +16253,6 @@
},
"node_modules/zip-stream/node_modules/buffer": {
"version": "6.0.3",
- "dev": true,
"funding": [
{
"type": "github",
@@ -16652,7 +16275,6 @@
},
"node_modules/zip-stream/node_modules/events": {
"version": "3.3.0",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -16660,7 +16282,6 @@
},
"node_modules/zip-stream/node_modules/ieee754": {
"version": "1.2.1",
- "dev": true,
"funding": [
{
"type": "github",
@@ -16679,7 +16300,6 @@
},
"node_modules/zip-stream/node_modules/readable-stream": {
"version": "4.7.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -16760,30 +16380,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 +16397,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 +16558,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 +16575,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 +16792,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 +16809,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 +16822,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..e63f7080e 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"axios": "^1.20.0",
"fast-uri": "^3.1.7",
"fast-xml-parser": "^5.5.6",
+ "@grpc/grpc-js": "^1.14.5",
"@isaacs/brace-expansion": "^5.0.1",
"minimatch@^3.0.0": {
"brace-expansion": "^1.1.20"
@@ -149,6 +150,7 @@
"docs",
"internal/*",
"lambdas/*",
+ "lambdas/letter-sla-breaches/*",
"pact-contracts",
"scripts/utilities/*",
"tests",
diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties
index cda56bc1d..6ad5ec975 100644
--- a/scripts/config/sonar-scanner.properties
+++ b/scripts/config/sonar-scanner.properties
@@ -9,7 +9,7 @@ sonar.exclusions=lambdas/**/src/__tests__/**/*
sonar.test.inclusions=**/*.test.*, **/*.spec.*
sonar.terraform.provider.aws.version=5.54.1
sonar.cpd.exclusions=**.test.*
-sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__,
+sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, internal/sql-test-helpers/**,
#sonar.python.coverage.reportPaths=.coverage/coverage.xml
sonar.javascript.lcov.reportPaths=lcov.info