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