diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index f1282be1f..051e5564c 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -77,6 +77,14 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: + # Pull testcontainers images in the background so the download overlaps + # the setup steps. Keep these references in sync with the images used in + # internal/datastore/src/__test__/db.ts and + # internal/sql-test-helpers/src/trino.ts. + - name: "Pre-pull test container images" + run: | + docker pull amazon/dynamodb-local > /dev/null 2>&1 & + docker pull trinodb/trino:476 > /dev/null 2>&1 & - name: "Checkout code" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup NodeJS diff --git a/docs/generate-includes.sh b/docs/generate-includes.sh index 75b217a97..2df5e62c4 100755 --- a/docs/generate-includes.sh +++ b/docs/generate-includes.sh @@ -24,6 +24,7 @@ cp ./lambdas/upsert-letter/README.md ./docs/_includes/components/generated/readm cp ./lambdas/update-letter-queue/README.md ./docs/_includes/components/generated/readmes/lambda-update-letter-queue.md cp ./lambdas/letter-updates-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-letter-updates-transformer.md cp ./lambdas/mi-updates-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-mi-updates-transformer.md +cp ./lambdas/eventpub-parquet-transformer/README.md ./docs/_includes/components/generated/readmes/lambda-eventpub-parquet-transformer.md cp ./lambdas/supplier-config-ingress/README.md ./docs/_includes/components/generated/readmes/lambda-supplier-config-ingress.md # Function documentation (internal packages) diff --git a/eslint.config.mjs b/eslint.config.mjs index 09a55d787..65dc77b54 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -115,6 +115,7 @@ export default defineConfig([ eslintImportResolverTypescript.createTypeScriptImportResolver({ project: [ 'lambdas/*/tsconfig.json', + 'lambdas/*/*/tsconfig.json', 'tests/tsconfig.json', 'internal/*/tsconfig.json', ], diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index b4d8adfb4..89bcc7e84 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -54,6 +54,8 @@ No requirements. | [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | +| [reports\_data\_retention\_non\_current\_days](#input\_reports\_data\_retention\_non\_current\_days) | Number of days to retain noncurrent reporting bucket object versions | `number` | `14` | no | +| [reports\_data\_retention\_policy\_days](#input\_reports\_data\_retention\_policy\_days) | Number of days to retain reporting bucket data | `number` | `90` | no | | [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | | [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no | | [supplier\_mock\_schedule\_expression](#input\_supplier\_mock\_schedule\_expression) | Schedule expression for supplier mock scheduler | `string` | `"rate(1 minute)"` | no | @@ -71,6 +73,7 @@ No requirements. | [ddb\_alarms\_suppliers](#module\_ddb\_alarms\_suppliers) | ../../modules/alarms-ddb | n/a | | [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | | [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/5.0.6/terraform-eventpub.zip | n/a | +| [eventpub\_parquet\_transformer](#module\_eventpub\_parquet\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [eventsub](#module\_eventsub) | ../../modules/eventsub | n/a | | [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | @@ -83,8 +86,13 @@ No requirements. | [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [mi\_updates\_transformer](#module\_mi\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | +| [populate\_sameday\_breaches](#module\_populate\_sameday\_breaches) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | +| [populate\_standard\_breaches](#module\_populate\_standard\_breaches) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | +| [populate\_supplier\_config\_cache](#module\_populate\_supplier\_config\_cache) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | | [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a | +| [s3bucket\_athena\_query\_results](#module\_s3bucket\_athena\_query\_results) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | +| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a | | [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a | | [sqs\_letter\_updates](#module\_sqs\_letter\_updates) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.5/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf new file mode 100644 index 000000000..e3939d786 --- /dev/null +++ b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf @@ -0,0 +1,19 @@ +resource "aws_athena_workgroup" "letter_sla_breaches" { + name = "${local.csi}-letter-sla-breaches" + description = "Athena workgroup for the letter SLA breaches state machine" + force_destroy = var.force_destroy + + configuration { + enforce_workgroup_configuration = true + publish_cloudwatch_metrics_enabled = true + + result_configuration { + output_location = "s3://${module.s3bucket_athena_query_results.bucket}/letter-sla-breaches/" + + encryption_configuration { + encryption_option = "SSE_KMS" + kms_key_arn = module.kms.key_arn + } + } + } +} diff --git a/infrastructure/terraform/components/api/athena_workgroup_reporting.tf b/infrastructure/terraform/components/api/athena_workgroup_reporting.tf new file mode 100644 index 000000000..08d7fa3ea --- /dev/null +++ b/infrastructure/terraform/components/api/athena_workgroup_reporting.tf @@ -0,0 +1,19 @@ +resource "aws_athena_workgroup" "reporting" { + name = local.csi + description = "Supplier API Athena Workgroup for ${var.environment}" + force_destroy = true + + configuration { + enforce_workgroup_configuration = true + + result_configuration { + expected_bucket_owner = var.aws_account_id + output_location = "s3://${module.s3bucket_reporting.bucket}/athena-output/" + + encryption_configuration { + encryption_option = "SSE_KMS" + kms_key_arn = module.kms.key_arn + } + } + } +} diff --git a/infrastructure/terraform/components/api/cloudwatch_log_group_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/cloudwatch_log_group_eventpub_parquet_firehose.tf new file mode 100644 index 000000000..95a9fc627 --- /dev/null +++ b/infrastructure/terraform/components/api/cloudwatch_log_group_eventpub_parquet_firehose.tf @@ -0,0 +1,7 @@ +resource "aws_cloudwatch_log_group" "eventpub_parquet_firehose" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "/aws/firehose/${local.csi}-eventpub-parquet" + kms_key_id = module.kms.key_arn + retention_in_days = var.log_retention_in_days +} diff --git a/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf new file mode 100644 index 000000000..e634886b4 --- /dev/null +++ b/infrastructure/terraform/components/api/cloudwatch_log_stream_eventpub_parquet_firehose.tf @@ -0,0 +1,6 @@ +resource "aws_cloudwatch_log_stream" "eventpub_parquet_firehose" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "extended_s3" + log_group_name = aws_cloudwatch_log_group.eventpub_parquet_firehose[0].name +} diff --git a/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf b/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf new file mode 100644 index 000000000..233e27a0c --- /dev/null +++ b/infrastructure/terraform/components/api/glue_catalog_table_eventpub_parquet.tf @@ -0,0 +1,60 @@ +resource "aws_glue_catalog_table" "eventpub_parquet" { + count = local.eventpub_parquet_enabled ? 1 : 0 + name = "${replace(local.csi, "-", "_")}_eventpub" + database_name = aws_glue_catalog_database.supplier.name + table_type = "EXTERNAL_TABLE" + + parameters = { + EXTERNAL = "TRUE" + classification = "parquet" + "parquet.compress" = "SNAPPY" + + # Athena derives partitions from these properties, so none are registered in Glue + "projection.enabled" = "true" + "projection.__year.type" = "integer" + "projection.__year.range" = "2025,2050" + "projection.__year.digits" = "4" + "projection.__month.type" = "integer" + "projection.__month.range" = "1,12" + "projection.__month.digits" = "2" + "projection.__day.type" = "integer" + "projection.__day.range" = "1,31" + "projection.__day.digits" = "2" + "storage.location.template" = "s3://${module.s3bucket_reporting.bucket}/parquet/eventpub/__year=$${__year}/__month=$${__month}/__day=$${__day}/" + } + + storage_descriptor { + location = "s3://${module.s3bucket_reporting.bucket}/parquet/eventpub/" + input_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat" + output_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat" + + ser_de_info { + name = "ParquetHiveSerDe" + serialization_library = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe" + } + + dynamic "columns" { + for_each = local.eventpub_parquet_table_columns + + content { + name = columns.value.name + type = columns.value.type + } + } + } + + partition_keys { + name = "__year" + type = "string" + } + + partition_keys { + name = "__month" + type = "string" + } + + partition_keys { + name = "__day" + type = "string" + } +} diff --git a/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf b/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf new file mode 100644 index 000000000..d17c10f33 --- /dev/null +++ b/infrastructure/terraform/components/api/glue_catalog_tables_supplier_config_cache.tf @@ -0,0 +1,108 @@ +locals { + supplier_config_cache_table_columns = { + supplier = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "channelType", type = "string" }, + { name = "dailyCapacity", type = "bigint" }, + { name = "id", type = "string" }, + { name = "name", type = "string" }, + { name = "status", type = "string" }, + ] + volume-group = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "description", type = "string" }, + { name = "endDate", type = "string" }, + { name = "id", type = "string" }, + { name = "name", type = "string" }, + { name = "startDate", type = "string" }, + { name = "status", type = "string" }, + ] + supplier-pack = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "approval", type = "string" }, + { name = "id", type = "string" }, + { name = "packSpecificationId", type = "string" }, + { name = "status", type = "string" }, + { name = "supplierId", type = "string" }, + ] + letter-variant = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "campaignIds", type = "array" }, + { name = "clientId", type = "string" }, + { name = "constraints", type = "struct,colourCoveragePercentage:struct,deliveryDays:struct,sheets:struct,sides:struct>" }, + { name = "description", type = "string" }, + { name = "id", type = "string" }, + { name = "name", type = "string" }, + { name = "packSpecificationIds", type = "array" }, + { name = "priority", type = "bigint" }, + { name = "status", type = "string" }, + { name = "supplierId", type = "string" }, + { name = "type", type = "string" }, + { name = "volumeGroupId", type = "string" }, + ] + pack-specification = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "assembly", type = "struct,insertIds:array,paper:struct,printColour:string>" }, + { name = "billingId", type = "string" }, + { name = "constraints", type = "struct,colourCoveragePercentage:struct,deliveryDays:struct,sheets:struct,sides:struct>" }, + { name = "createdAt", type = "string" }, + { name = "description", type = "string" }, + { name = "id", type = "string" }, + { name = "name", type = "string" }, + { name = "postage", type = "struct" }, + { name = "status", type = "string" }, + { name = "updatedAt", type = "string" }, + { name = "version", type = "bigint" }, + ] + supplier-allocation = [ + { name = "pk", type = "string" }, + { name = "sk", type = "string" }, + { name = "allocationPercentage", type = "double" }, + { name = "id", type = "string" }, + { name = "status", type = "string" }, + { name = "supplier", type = "string" }, + { name = "volumeGroup", type = "string" }, + ] + } +} + +resource "aws_glue_catalog_table" "supplier_config_cache" { + for_each = local.supplier_config_cache_table_columns + name = "${replace(local.csi, "-", "_")}_${replace(each.key, "-", "_")}" + database_name = aws_glue_catalog_database.supplier.name + table_type = "EXTERNAL_TABLE" + + parameters = { + EXTERNAL = "TRUE" + classification = "json" + } + + storage_descriptor { + location = "s3://${module.s3bucket_reporting.bucket}/supplier_config_cache/${each.key}/" + input_format = "org.apache.hadoop.mapred.TextInputFormat" + output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat" + + ser_de_info { + name = "OpenXJsonSerDe" + serialization_library = "org.openx.data.jsonserde.JsonSerDe" + + parameters = { + "case.insensitive" = "false" + } + } + + dynamic "columns" { + for_each = each.value + + content { + name = columns.value.name + type = columns.value.type + } + } + } +} diff --git a/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf new file mode 100644 index 000000000..c39074016 --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_firehose.tf @@ -0,0 +1,18 @@ +resource "aws_iam_role" "eventpub_parquet_firehose" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-eventpub-parquet-firehose" + assume_role_policy = data.aws_iam_policy_document.eventpub_parquet_firehose_assume_role.json +} + +data "aws_iam_policy_document" "eventpub_parquet_firehose_assume_role" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["firehose.amazonaws.com"] + } + } +} diff --git a/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf new file mode 100644 index 000000000..50363d68b --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_eventpub_parquet_sns.tf @@ -0,0 +1,18 @@ +resource "aws_iam_role" "eventpub_parquet_sns" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-eventpub-parquet-sns" + assume_role_policy = data.aws_iam_policy_document.eventpub_parquet_sns_assume_role.json +} + +data "aws_iam_policy_document" "eventpub_parquet_sns_assume_role" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["sns.amazonaws.com"] + } + } +} diff --git a/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf new file mode 100644 index 000000000..97a35bad2 --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_letter_sla_breaches.tf @@ -0,0 +1,17 @@ +resource "aws_iam_role" "letter_sla_breaches" { + name = "${local.csi}-letter-sla-breaches-sfn" + description = "Execution role for the letter SLA breaches state machine" + assume_role_policy = data.aws_iam_policy_document.letter_sla_breaches_trust_policy.json +} + +data "aws_iam_policy_document" "letter_sla_breaches_trust_policy" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["states.amazonaws.com"] + } + } +} diff --git a/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf new file mode 100644 index 000000000..ae8273301 --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_firehose.tf @@ -0,0 +1,79 @@ +resource "aws_iam_role_policy" "eventpub_parquet_firehose" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-eventpub-parquet-firehose" + role = aws_iam_role.eventpub_parquet_firehose[0].id + policy = data.aws_iam_policy_document.eventpub_parquet_firehose[0].json +} + +data "aws_iam_policy_document" "eventpub_parquet_firehose" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + statement { + sid = "AllowDestinationS3Access" + effect = "Allow" + + actions = [ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject", + ] + + resources = [ + module.s3bucket_reporting.arn, + "${module.s3bucket_reporting.arn}/parquet/*", + ] + } + + statement { + sid = "AllowKms" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:GenerateDataKey*", + ] + + resources = [module.kms.key_arn] + } + + statement { + sid = "AllowInvokeTransformer" + effect = "Allow" + + actions = [ + "lambda:InvokeFunction", + "lambda:GetFunctionConfiguration", + ] + + resources = ["${module.eventpub_parquet_transformer.function_arn}:$LATEST"] + } + + statement { + sid = "AllowGlueSchemaRead" + effect = "Allow" + + actions = [ + "glue:GetTable", + "glue:GetTableVersion", + "glue:GetTableVersions", + ] + + resources = [ + aws_glue_catalog_table.eventpub_parquet[0].arn, + aws_glue_catalog_database.supplier.arn, + "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog", + ] + } + + statement { + sid = "AllowLogging" + effect = "Allow" + actions = ["logs:PutLogEvents"] + resources = ["${aws_cloudwatch_log_group.eventpub_parquet_firehose[0].arn}:*"] + } +} diff --git a/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf new file mode 100644 index 000000000..b7e41a764 --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_policy_eventpub_parquet_sns.tf @@ -0,0 +1,30 @@ +resource "aws_iam_role_policy" "eventpub_parquet_sns" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-eventpub-parquet-sns" + role = aws_iam_role.eventpub_parquet_sns[0].id + policy = data.aws_iam_policy_document.eventpub_parquet_sns[0].json +} + +data "aws_iam_policy_document" "eventpub_parquet_sns" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + statement { + sid = "AllowFirehoseDelivery" + effect = "Allow" + + actions = [ + "firehose:PutRecord", + "firehose:PutRecordBatch", + ] + + resources = [aws_kinesis_firehose_delivery_stream.eventpub_parquet[0].arn] + } + + statement { + sid = "AllowKms" + effect = "Allow" + actions = ["kms:GenerateDataKey", "kms:Decrypt"] + resources = [module.kms.key_arn] + } +} diff --git a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf new file mode 100644 index 000000000..f8076c49a --- /dev/null +++ b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf @@ -0,0 +1,27 @@ +resource "aws_iam_role_policy" "letter_sla_breaches" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-letter-sla-breaches-sfn" + role = aws_iam_role.letter_sla_breaches.id + policy = data.aws_iam_policy_document.letter_sla_breaches_policy.json +} + +data "aws_iam_policy_document" "letter_sla_breaches_policy" { + statement { + sid = "AllowListOwnExecutions" + effect = "Allow" + actions = ["states:ListExecutions"] + resources = [local.letter_sla_breaches_state_machine_arn] + } + + statement { + sid = "AllowInvokeLambdas" + effect = "Allow" + actions = ["lambda:InvokeFunction"] + resources = [ + module.populate_supplier_config_cache.function_arn, + module.populate_sameday_breaches.function_arn, + module.populate_standard_breaches[0].function_arn, + ] + } +} diff --git a/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf b/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf new file mode 100644 index 000000000..4dcdec6e2 --- /dev/null +++ b/infrastructure/terraform/components/api/kinesis_firehose_delivery_stream_eventpub_parquet.tf @@ -0,0 +1,66 @@ +resource "aws_kinesis_firehose_delivery_stream" "eventpub_parquet" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = "${local.csi}-eventpub-parquet" + destination = "extended_s3" + + server_side_encryption { + enabled = true + key_type = "CUSTOMER_MANAGED_CMK" + key_arn = module.kms.key_arn + } + + extended_s3_configuration { + role_arn = aws_iam_role.eventpub_parquet_firehose[0].arn + bucket_arn = module.s3bucket_reporting.arn + + prefix = "parquet/eventpub/__year=!{timestamp:yyyy}/__month=!{timestamp:MM}/__day=!{timestamp:dd}/" + error_output_prefix = "parquet/errors/eventpub/!{firehose:error-output-type}/!{timestamp:yyyy}-!{timestamp:MM}-!{timestamp:dd}/" + + buffering_size = 128 + buffering_interval = 300 + + processing_configuration { + enabled = true + + processors { + type = "Lambda" + + parameters { + parameter_name = "LambdaArn" + parameter_value = "${module.eventpub_parquet_transformer.function_arn}:$LATEST" + } + parameters { + parameter_name = "RoleArn" + parameter_value = aws_iam_role.eventpub_parquet_firehose[0].arn + } + } + } + + data_format_conversion_configuration { + input_format_configuration { + deserializer { + open_x_json_ser_de {} + } + } + + output_format_configuration { + serializer { + parquet_ser_de {} + } + } + + schema_configuration { + database_name = aws_glue_catalog_database.supplier.name + role_arn = aws_iam_role.eventpub_parquet_firehose[0].arn + table_name = aws_glue_catalog_table.eventpub_parquet[0].name + } + } + + cloudwatch_logging_options { + enabled = true + log_group_name = aws_cloudwatch_log_group.eventpub_parquet_firehose[0].name + log_stream_name = aws_cloudwatch_log_stream.eventpub_parquet_firehose[0].name + } + } +} diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 6b4d90305..24900a1f2 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -1,4 +1,34 @@ locals { + eventpub_parquet_enabled = local.event_cache_bucket_name != null + + # Column names must be lowercase; OpenXJsonSerDe matches JSON keys case-insensitively. + eventpub_parquet_table_columns = [ + { name = "specversion", type = "string" }, + { name = "id", type = "string" }, + { name = "type", type = "string" }, + { name = "plane", type = "string" }, + { name = "dataschema", type = "string" }, + { name = "dataschemaversion", type = "string" }, + { name = "source", type = "string" }, + { name = "subject", type = "string" }, + { name = "time", type = "string" }, + { name = "recordedtime", type = "string" }, + { name = "datacontenttype", type = "string" }, + { name = "traceparent", type = "string" }, + { name = "tracestate", type = "string" }, + { name = "partitionkey", type = "string" }, + { name = "sampledrate", type = "bigint" }, + { name = "sequence", type = "string" }, + { name = "severitytext", type = "string" }, + { name = "data_json", type = "string" }, + { name = "domainid", type = "string" }, + { name = "supplierid", type = "string" }, + { name = "groupid", type = "string" }, + { name = "campaignid", type = "string" }, + { name = "clientid", type = "string" }, + { name = "status", type = "string" }, + ] + aws_lambda_functions_dir_path = "../../../../lambdas" root_domain_name = "${var.environment}.${local.acct.route53_zone_names["supplier-api"]}" # e.g. [main|dev|abxy0].supplier-api.[dev|nonprod|prod].nhsnotify.national.nhs.uk root_domain_id = local.acct.route53_zone_ids["supplier-api"] @@ -47,9 +77,16 @@ locals { event_cache_bucket_name = lookup(module.eventpub.s3_bucket_event_cache, "bucket", null) eventsub_event_cache_bucket_name = lookup(module.eventsub.s3_bucket_event_cache, "bucket", null) + reporting_bucket_name = lookup(module.s3bucket_reporting, "bucket", null) + + # The Glue crawler names tables after the crawled bucket, replacing characters that are invalid in table names + event_cache_glue_table_name = local.event_cache_bucket_name != null ? lower(replace(local.event_cache_bucket_name, "/[^a-zA-Z0-9_]/", "_")) : null csoc_api_gw_log_destination_arn = format("arn:aws:logs:%s:%s:destination:api_gateway_log_destination", var.region, var.csoc_destination_account ) + + letter_sla_breaches_state_machine_name = "${local.csi}-letter-sla-breaches" + letter_sla_breaches_state_machine_arn = "arn:aws:states:${var.region}:${var.aws_account_id}:stateMachine:${local.letter_sla_breaches_state_machine_name}" } diff --git a/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf b/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf new file mode 100644 index 000000000..4f0aa4be2 --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_eventpub_parquet_transformer.tf @@ -0,0 +1,35 @@ +module "eventpub_parquet_transformer" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip" + + function_name = "eventpub-parquet-transformer" + description = "Flattens EventPub events for parquet delivery by Firehose" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "eventpub-parquet-transformer/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 256 + timeout = 60 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.destination_arn + odin_log_destination_arn = local.odin_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = local.common_lambda_env_vars +} diff --git a/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf new file mode 100644 index 000000000..020c4dd1c --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_populate_sameday_breaches.tf @@ -0,0 +1,55 @@ +module "populate_sameday_breaches" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip" + + function_name = "populate_sameday_breaches" + description = "Populates same-day breaches for the letter SLA breaches state machine" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.populate_sameday_breaches_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "letter-sla-breaches/populate-sameday-breaches/dist" + function_include_common = true + handler_function_name = "populateSamedayBreachesHandler" + runtime = "nodejs22.x" + memory = 128 + timeout = 29 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.destination_arn + odin_log_destination_arn = local.odin_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = {} +} + +data "aws_iam_policy_document" "populate_sameday_breaches_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } +} diff --git a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf new file mode 100644 index 000000000..b994f3af8 --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf @@ -0,0 +1,131 @@ +module "populate_standard_breaches" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip" + + function_name = "populate_standard_breaches" + description = "Populates standard breaches for the letter SLA breaches state machine" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.populate_standard_breaches_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "letter-sla-breaches/populate-standard-breaches/dist" + function_include_common = true + handler_function_name = "populateStandardBreachesHandler" + runtime = "nodejs22.x" + memory = 128 + timeout = 300 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.destination_arn + odin_log_destination_arn = local.odin_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + ATHENA_WORKGROUP = aws_athena_workgroup.letter_sla_breaches.name + ATHENA_DATABASE = aws_glue_catalog_database.supplier.name + EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name + } +} + +data "aws_iam_policy_document" "populate_standard_breaches_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + + statement { + sid = "AllowAthenaQueries" + effect = "Allow" + + actions = [ + "athena:GetQueryExecution", + "athena:GetQueryResults", + "athena:StartQueryExecution", + "athena:StopQueryExecution", + ] + + resources = [ + aws_athena_workgroup.letter_sla_breaches.arn, + ] + } + + statement { + sid = "AllowGlueCatalogRead" + effect = "Allow" + + actions = [ + "glue:GetDatabase", + "glue:GetPartition", + "glue:GetPartitions", + "glue:GetTable", + "glue:GetTables", + ] + + resources = [ + "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog", + aws_glue_catalog_database.supplier.arn, + "arn:aws:glue:${var.region}:${var.aws_account_id}:table/${aws_glue_catalog_database.supplier.name}/*", + ] + } + + statement { + sid = "AllowReadReportingBucket" + effect = "Allow" + + actions = [ + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + ] + + resources = [ + "arn:aws:s3:::${local.reporting_bucket_name}", + "arn:aws:s3:::${local.reporting_bucket_name}/*", + ] + } + + statement { + sid = "AllowAthenaQueryResultsBucket" + effect = "Allow" + + actions = [ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListMultipartUploadParts", + "s3:PutObject", + ] + + resources = [ + module.s3bucket_athena_query_results.arn, + "${module.s3bucket_athena_query_results.arn}/letter-sla-breaches/*", + ] + } +} diff --git a/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf b/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf new file mode 100644 index 000000000..86339dda2 --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_populate_supplier_config_cache.tf @@ -0,0 +1,72 @@ +module "populate_supplier_config_cache" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip" + + function_name = "populate_supplier_config_cache" + description = "Populates the supplier config cache for the letter SLA breaches state machine" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.populate_supplier_config_cache_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "letter-sla-breaches/populate-supplier-config-cache/dist" + function_include_common = true + handler_function_name = "populateSupplierConfigCacheHandler" + runtime = "nodejs22.x" + memory = 128 + timeout = 29 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.destination_arn + odin_log_destination_arn = local.odin_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name + EVENT_CACHE_BUCKET_NAME = module.s3bucket_reporting.bucket + } +} + +data "aws_iam_policy_document" "populate_supplier_config_cache_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + + statement { + sid = "AllowQuerySupplierConfigTable" + effect = "Allow" + actions = ["dynamodb:Query"] + resources = [aws_dynamodb_table.supplier-configuration.arn] + } + + statement { + sid = "AllowWriteSupplierConfigCache" + effect = "Allow" + actions = ["s3:PutObject"] + resources = ["${module.s3bucket_reporting.arn}/supplier_config_cache/*"] + } +} diff --git a/infrastructure/terraform/components/api/module_s3bucket_reporting.tf b/infrastructure/terraform/components/api/module_s3bucket_reporting.tf new file mode 100644 index 000000000..f3e3d0a35 --- /dev/null +++ b/infrastructure/terraform/components/api/module_s3bucket_reporting.tf @@ -0,0 +1,79 @@ +module "s3bucket_reporting" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip" + + name = "supapi-reporting" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = var.component + + kms_key_arn = module.kms.key_arn + + policy_documents = [data.aws_iam_policy_document.s3bucket_reporting.json] + + force_destroy = var.force_destroy + + lifecycle_rules = [ + { + prefix = "athena-output" + enabled = true + + expiration = { + days = var.reports_data_retention_policy_days + } + + noncurrent_version_expiration = { + noncurrent_days = var.reports_data_retention_non_current_days + } + }, + ] + + default_tags = { + NHSE-Enable-S3-Backup-Acct = "True", + } +} + +data "aws_iam_policy_document" "s3bucket_reporting" { + statement { + sid = "AllowManagedAccountsToList" + effect = "Allow" + + actions = [ + "s3:ListBucket", + ] + + resources = [ + module.s3bucket_reporting.arn, + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } + + statement { + sid = "AllowManagedAccountsToGet" + effect = "Allow" + + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + + resources = [ + "${module.s3bucket_reporting.arn}/*", + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } +} diff --git a/infrastructure/terraform/components/api/resources/letter_sla_breaches.json b/infrastructure/terraform/components/api/resources/letter_sla_breaches.json new file mode 100644 index 000000000..456a54ff1 --- /dev/null +++ b/infrastructure/terraform/components/api/resources/letter_sla_breaches.json @@ -0,0 +1,92 @@ +{ + "Comment": "Identifies letters in breach of SLA for a given date (defaults to today)", + "QueryLanguage": "JSONata", + "StartAt": "CheckForRunningExecutions", + "States": { + "AlreadyRunning?": { + "Choices": [ + { + "Condition": "{% $count($states.input.Executions) > 1 %}", + "Next": "ExitAlreadyRunning" + } + ], + "Default": "PopulateSupplierConfigCache", + "Type": "Choice" + }, + "CheckForRunningExecutions": { + "Arguments": { + "StateMachineArn": "${STATE_MACHINE_ARN}", + "StatusFilter": "RUNNING" + }, + "Assign": { + "date": "{% $exists($states.context.Execution.Input.date) ? $states.context.Execution.Input.date : $substring($states.context.Execution.StartTime, 0, 10) %}" + }, + "Next": "AlreadyRunning?", + "Resource": "arn:aws:states:::aws-sdk:sfn:listExecutions", + "Type": "Task" + }, + "ExitAlreadyRunning": { + "Type": "Succeed" + }, + "PopulateBreachesInParallel": { + "Branches": [ + { + "StartAt": "PopulateSamedayBreaches", + "States": { + "PopulateSamedayBreaches": { + "Arguments": { + "FunctionName": "${POPULATE_SAMEDAY_BREACHES_FUNCTION_ARN}", + "Payload": { + "date": "{% $date %}" + } + }, + "End": true, + "Output": { + "populateSamedayBreaches": "{% $states.result.Payload %}" + }, + "Resource": "arn:aws:states:::lambda:invoke", + "Type": "Task" + } + } + }, + { + "StartAt": "PopulateStandardBreaches", + "States": { + "PopulateStandardBreaches": { + "Arguments": { + "FunctionName": "${POPULATE_STANDARD_BREACHES_FUNCTION_ARN}", + "Payload": { + "date": "{% $date %}" + } + }, + "End": true, + "Output": { + "populateStandardBreaches": "{% $states.result.Payload %}" + }, + "Resource": "arn:aws:states:::lambda:invoke", + "Type": "Task" + } + } + } + ], + "End": true, + "Output": { + "populateSamedayBreaches": "{% $states.result[0].populateSamedayBreaches %}", + "populateStandardBreaches": "{% $states.result[1].populateStandardBreaches %}", + "populateSupplierConfigCache": "{% $states.input.populateSupplierConfigCache %}" + }, + "Type": "Parallel" + }, + "PopulateSupplierConfigCache": { + "Arguments": { + "FunctionName": "${POPULATE_SUPPLIER_CONFIG_CACHE_FUNCTION_ARN}" + }, + "Next": "PopulateBreachesInParallel", + "Output": { + "populateSupplierConfigCache": "{% $states.result.Payload %}" + }, + "Resource": "arn:aws:states:::lambda:invoke", + "Type": "Task" + } + } +} diff --git a/infrastructure/terraform/components/api/s3_athena_query_results.tf b/infrastructure/terraform/components/api/s3_athena_query_results.tf new file mode 100644 index 000000000..68d514b78 --- /dev/null +++ b/infrastructure/terraform/components/api/s3_athena_query_results.tf @@ -0,0 +1,73 @@ +module "s3bucket_athena_query_results" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip" + + name = "athena-query-results" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = var.component + + acl = "private" + force_destroy = var.force_destroy + versioning = false + kms_key_arn = module.kms.key_arn + + lifecycle_rules = [ + { + enabled = true + + expiration = { + days = "7" + } + + abort_incomplete_multipart_upload = { + days = "1" + } + } + ] + + policy_documents = [ + data.aws_iam_policy_document.s3bucket_athena_query_results.json + ] + + bucket_logging_target = { + bucket = local.acct.s3_buckets["access_logs"]["id"] + } + + public_access = { + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + } + + default_tags = { + Name = "Athena Query Results" + } +} + +data "aws_iam_policy_document" "s3bucket_athena_query_results" { + statement { + sid = "DontAllowNonSecureConnection" + effect = "Deny" + actions = ["s3:*"] + + resources = [ + module.s3bucket_athena_query_results.arn, + "${module.s3bucket_athena_query_results.arn}/*", + ] + + principals { + type = "AWS" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} diff --git a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf new file mode 100644 index 000000000..e36a69e26 --- /dev/null +++ b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf @@ -0,0 +1,13 @@ +resource "aws_sfn_state_machine" "letter_sla_breaches" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + name = local.letter_sla_breaches_state_machine_name + role_arn = aws_iam_role.letter_sla_breaches.arn + + definition = templatefile("${path.module}/resources/letter_sla_breaches.json", { + STATE_MACHINE_ARN = local.letter_sla_breaches_state_machine_arn + POPULATE_SUPPLIER_CONFIG_CACHE_FUNCTION_ARN = module.populate_supplier_config_cache.function_arn + POPULATE_SAMEDAY_BREACHES_FUNCTION_ARN = module.populate_sameday_breaches.function_arn + POPULATE_STANDARD_BREACHES_FUNCTION_ARN = module.populate_standard_breaches[0].function_arn + }) +} diff --git a/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf b/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf new file mode 100644 index 000000000..da3237198 --- /dev/null +++ b/infrastructure/terraform/components/api/sns_topic_subscription_eventpub_parquet.tf @@ -0,0 +1,10 @@ +# Raw delivery so Firehose receives the bare event rather than the SNS envelope +resource "aws_sns_topic_subscription" "eventpub_parquet" { + count = local.eventpub_parquet_enabled ? 1 : 0 + + topic_arn = module.eventpub.sns_topic.arn + protocol = "firehose" + subscription_role_arn = aws_iam_role.eventpub_parquet_sns[0].arn + endpoint = aws_kinesis_firehose_delivery_stream.eventpub_parquet[0].arn + raw_message_delivery = true +} diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index f1499c3fc..0486ee22a 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -296,3 +296,15 @@ variable "event_source_filter" { type = list(string) description = "SNS subscription source filter values" } + +variable "reports_data_retention_policy_days" { + type = number + description = "Number of days to retain reporting bucket data" + default = 90 +} + +variable "reports_data_retention_non_current_days" { + type = number + description = "Number of days to retain noncurrent reporting bucket object versions" + default = 14 +} diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 7e5f18edf..76c245175 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -9,6 +9,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { DatastoreConfig } from "../config"; export async function setupDynamoDBContainer() { + // Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync. const container = await new GenericContainer("amazon/dynamodb-local") .withExposedPorts(8000) .start(); diff --git a/internal/sql-test-helpers/README.md b/internal/sql-test-helpers/README.md new file mode 100644 index 000000000..819b06b30 --- /dev/null +++ b/internal/sql-test-helpers/README.md @@ -0,0 +1,46 @@ + + +# @internal/sql-test-helpers + +## Purpose + +Test-only helpers for running SQL queries against databases in [testcontainers](https://node.testcontainers.org/), so that query builders can be checked against a real SQL engine using known test data. + +## General Structure highlights + +- **`src/trino.ts`**: Trino helpers, which talk to Trino over its REST API using `fetch`: + - `setupTrinoContainer()` — starts a `trinodb/trino` `GenericContainer` and returns a `TrinoContext` (`container`, `endpoint`). + - `runTrinoStatement(context, sql)` — runs a statement against the built-in `memory` catalog, follows result pagination and returns rows as `Record` (NULL → `undefined`). + - `runParameterisedQueryOnTrino(context, query)` — runs an Athena-style `{ queryString, executionParameters }` query via `EXECUTE IMMEDIATE ... USING`. Execution parameters are SQL literal expressions, as in Athena (for example `"'2026-10-14T12:00:00.000Z'"`). + +## Usage + +Add the package as a **devDependency** of the consuming workspace and import it from test code only: + +```json +"devDependencies": { + "@internal/sql-test-helpers": "*" +} +``` + +```ts +import { + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "@internal/sql-test-helpers"; + +const context = await setupTrinoContainer(); +await runTrinoStatement(context, "CREATE TABLE event_cache (...)"); +const rows = await runParameterisedQueryOnTrino(context, buildQuery(...)); +await context.container.stop(); +``` + +## Nuances and Peculiarities + +- Must never be imported by production code. Because consumers list it under `devDependencies`, ESLint's `import-x/no-extraneous-dependencies` rule reports any import from non-test files. +- Has no unit tests of its own and is excluded from coverage (see `sonar.coverage.exclusions` in `scripts/config/sonar-scanner.properties`). It is checked through the tests that use it. +- Tests that use it require Docker. The first run pulls the Trino image (about 1 GB), so allow a generous `beforeAll` timeout (for example `180_000` ms). In CI, the image is pulled in the background at the start of the unit test job in `.github/workflows/stage-2-test.yaml`. If you change `TRINO_IMAGE`, update that step too. +- Trino SQL is close to, but not identical to, Athena's engine version. The helpers check query logic, not every Athena-specific behaviour. + + diff --git a/internal/sql-test-helpers/package.json b/internal/sql-test-helpers/package.json new file mode 100644 index 000000000..ec64c9abe --- /dev/null +++ b/internal/sql-test-helpers/package.json @@ -0,0 +1,17 @@ +{ + "dependencies": { + "testcontainers": "^12.2.0" + }, + "description": "Test-only helpers for running SQL queries against containerised databases", + "license": "MIT", + "main": "src/index.ts", + "name": "@internal/sql-test-helpers", + "private": true, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "echo \"Test helpers package has no unit tests\"", + "typecheck": "tsc --noEmit" + }, + "version": "0.1.0" +} diff --git a/internal/sql-test-helpers/src/index.ts b/internal/sql-test-helpers/src/index.ts new file mode 100644 index 000000000..79ebbf71c --- /dev/null +++ b/internal/sql-test-helpers/src/index.ts @@ -0,0 +1,8 @@ +export { + type SqlQuery, + type SqlRow, + type TrinoContext, + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "./trino"; diff --git a/internal/sql-test-helpers/src/trino.ts b/internal/sql-test-helpers/src/trino.ts new file mode 100644 index 000000000..2040e6f3e --- /dev/null +++ b/internal/sql-test-helpers/src/trino.ts @@ -0,0 +1,181 @@ +import { GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +// Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync. +const TRINO_IMAGE = "trinodb/trino:476"; +const TRINO_PORT = 8080; + +// Structurally compatible with Athena's query/row shapes, so Athena query +// builders can be exercised against Trino without depending on them here. +export type SqlQuery = { + queryString: string; + executionParameters?: string[]; +}; + +export type SqlRow = Record; + +type TrinoResponse = { + columns?: { name: string }[]; + data?: unknown[][]; + nextUri?: string; + error?: { message: string }; +}; + +export type TrinoContext = { + container: StartedTestContainer; + endpoint: string; + logs: string[]; +}; + +const NODE_READY_TIMEOUT_MS = 60_000; +const NODE_READY_POLL_MS = 500; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// Trino can report "No nodes available to run query" when the coordinator has +// not (or no longer) registered a worker, e.g. just after "SERVER STARTED" or +// on a loaded CI runner. It is raised before execution starts, so retrying is +// safe even for non-idempotent statements. +const isNoNodesError = (error: unknown) => + error instanceof Error && error.message.includes("No nodes available"); + +export async function setupTrinoContainer(): Promise { + // Buffer logs from the start so they are available even if startup fails. + const logs: string[] = []; + let container: StartedTestContainer; + + try { + container = await new GenericContainer(TRINO_IMAGE) + .withExposedPorts(TRINO_PORT) + .withWaitStrategy(Wait.forLogMessage("======== SERVER STARTED ========")) + .withStartupTimeout(120_000) + .withLogConsumer((stream) => { + stream.on("data", (chunk) => logs.push(chunk.toString())); + }) + .start(); + } catch (error) { + printLogs(logs); + throw error; + } + + const endpoint = `http://${container.getHost()}:${container.getMappedPort(TRINO_PORT)}`; + const context = { container, endpoint, logs }; + + try { + await runTrinoStatement(context, "SELECT 1"); + } catch (error) { + printLogs(logs); + await container.stop(); + throw error; + } + + return context; +} + +function printLogs(logs: string[]) { + // eslint-disable-next-line no-console + console.error(`Trino container logs:\n${logs.join("")}`); +} + +async function readTrinoResponse(response: Response): Promise { + if (!response.ok) { + throw new Error( + `Trino request failed: ${response.status} ${await response.text()}`, + ); + } + + const body = (await response.json()) as TrinoResponse; + + if (body.error) { + throw new Error(`Trino query failed: ${body.error.message}`); + } + + return body; +} + +function toRow(columns: string[], values: unknown[]): SqlRow { + return Object.fromEntries( + columns.map((name, i) => { + const value = values.at(i); + return [name, value === null ? undefined : String(value)]; + }), + ); +} + +// Runs a statement via the Trino REST API against the in-memory catalog. +export async function runTrinoStatement( + context: TrinoContext, + sql: string, +): Promise { + const deadline = Date.now() + NODE_READY_TIMEOUT_MS; + + for (;;) { + try { + return await executeTrinoStatement(context, sql); + } catch (error) { + if (!isNoNodesError(error) || Date.now() >= deadline) { + printLogs(context.logs); + throw error; + } + } + + await sleep(NODE_READY_POLL_MS); + } +} + +async function executeTrinoStatement( + context: TrinoContext, + sql: string, +): Promise { + let body = await readTrinoResponse( + await fetch(`${context.endpoint}/v1/statement`, { + method: "POST", + headers: { + "X-Trino-User": "test", + "X-Trino-Catalog": "memory", + "X-Trino-Schema": "default", + }, + body: sql, + }), + ); + + let columns: string[] = []; + const rows: SqlRow[] = []; + + while (true) { + if (body.columns) { + columns = body.columns.map((column) => column.name); + } + + const currentColumns = columns; + rows.push( + ...(body.data ?? []).map((values) => toRow(currentColumns, values)), + ); + + if (!body.nextUri) { + return rows; + } + + body = await readTrinoResponse( + await fetch(body.nextUri, { headers: { "X-Trino-User": "test" } }), + ); + } +} + +// Emulates Athena parameterised queries: each execution parameter is +// substituted into the query as a SQL literal expression. +export async function runParameterisedQueryOnTrino( + context: TrinoContext, + query: SqlQuery, +): Promise { + const escapedQuery = query.queryString.replaceAll("'", "''"); + const parameters = query.executionParameters ?? []; + const using = parameters.length > 0 ? ` USING ${parameters.join(", ")}` : ""; + + return runTrinoStatement( + context, + `EXECUTE IMMEDIATE '${escapedQuery}'${using}`, + ); +} diff --git a/internal/sql-test-helpers/tsconfig.json b/internal/sql-test-helpers/tsconfig.json new file mode 100644 index 000000000..70ae3804b --- /dev/null +++ b/internal/sql-test-helpers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": {}, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} diff --git a/lambdas/eventpub-parquet-transformer/README.md b/lambdas/eventpub-parquet-transformer/README.md new file mode 100644 index 000000000..eb1a5c458 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/README.md @@ -0,0 +1,17 @@ + + +# EventPub Parquet Transformer Lambda + +## Purpose + +Firehose data-transformation processor for the EventPub parquet delivery stream. Flattens each CloudEvent so Firehose can convert it to Parquet for Athena. + +## General Flow + +1. Firehose invokes the Lambda with a batch of base64 encoded CloudEvents. +2. Each event is parsed and returned as a flat JSON record: the envelope attributes, the raw `data` payload as `data_json`, and the promoted `domainid`, `supplierid`, `groupid`, `campaignid` and `clientid` properties. +3. Records that are not valid JSON objects are returned as `ProcessingFailed` and are written to the stream's error prefix. + +## Key Integration Points + +- **Kinesis Data Firehose**: the `eventpub-parquet` delivery stream. diff --git a/lambdas/eventpub-parquet-transformer/jest.config.ts b/lambdas/eventpub-parquet-transformer/jest.config.ts new file mode 100644 index 000000000..93ec73a53 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/jest.config.ts @@ -0,0 +1,63 @@ +import type { Config } from "jest"; + +export const baseJestConfig: Config = { + preset: "ts-jest", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // The directory where Jest should output its coverage files + coverageDirectory: "./.reports/unit/coverage", + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + // Use this configuration option to add custom reporters to Jest + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + // The test environment that will be used for testing + testEnvironment: "jsdom", +}; + +const utilsJestConfig = { + ...baseJestConfig, + + testEnvironment: "node", + + coveragePathIgnorePatterns: [ + ...(baseJestConfig.coveragePathIgnorePatterns ?? []), + "zod-validators.ts", + ], +}; + +export default utilsJestConfig; diff --git a/lambdas/eventpub-parquet-transformer/package.json b/lambdas/eventpub-parquet-transformer/package.json new file mode 100644 index 000000000..7512fb336 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/package.json @@ -0,0 +1,16 @@ +{ + "dependencies": { + "aws-lambda": "^1.0.7", + "esbuild": "0.28.2" + }, + "name": "nhs-notify-supplier-api-eventpub-parquet-transformer", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts b/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts new file mode 100644 index 000000000..677cf5752 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/src/__tests__/transform-record.test.ts @@ -0,0 +1,92 @@ +import { FirehoseTransformationEvent } from "aws-lambda"; +import { handler } from ".."; +import transformRecord from "../transform-record"; + +const encode = (value: unknown) => + Buffer.from( + typeof value === "string" ? value : JSON.stringify(value), + "utf8", + ).toString("base64"); + +const decode = (data: string) => + JSON.parse(Buffer.from(data, "base64").toString("utf8")); + +describe("transformRecord", () => { + it("flattens the envelope, keeps data as JSON and promotes properties", () => { + const data = { + domainId: "d1", + supplierId: "s1", + groupId: "g1", + campaignId: "c1", + clientId: "cl1", + status: "PRINTED", + }; + + const out = transformRecord({ + recordId: "r1", + data: encode({ id: "e1", type: "t", time: "now", data }), + }); + + expect(out.result).toBe("Ok"); + expect(out.recordId).toBe("r1"); + expect(Buffer.from(out.data!, "base64").toString().endsWith("\n")).toBe( + true, + ); + expect(decode(out.data!)).toEqual({ + id: "e1", + type: "t", + time: "now", + data_json: JSON.stringify(data), + domainid: "d1", + supplierid: "s1", + groupid: "g1", + campaignid: "c1", + clientid: "cl1", + status: "PRINTED", + }); + }); + + it("leaves promoted properties empty when data is missing or not an object", () => { + const missing = decode( + transformRecord({ recordId: "r", data: encode({ id: "e" }) }).data!, + ); + const scalar = decode( + transformRecord({ recordId: "r", data: encode({ id: "e", data: 1 }) }) + .data!, + ); + + expect(missing).toEqual({ id: "e", data_json: "null" }); + expect(scalar).toEqual({ id: "e", data_json: "1" }); + }); + + it.each([["not json"], ["[1]"], ["null"]])( + "returns ProcessingFailed for %s", + (payload) => { + const record = { recordId: "r", data: encode(payload) }; + + expect(transformRecord(record)).toEqual({ + recordId: "r", + result: "ProcessingFailed", + data: record.data, + }); + }, + ); +}); + +describe("handler", () => { + it("transforms every record in the batch", async () => { + const event = { + records: [ + { recordId: "1", data: encode({ id: "a" }) }, + { recordId: "2", data: encode("bad") }, + ], + } as unknown as FirehoseTransformationEvent; + + const res = await handler(event, {} as never, () => {}); + + expect(res!.records.map((r) => r.result)).toEqual([ + "Ok", + "ProcessingFailed", + ]); + }); +}); diff --git a/lambdas/eventpub-parquet-transformer/src/index.ts b/lambdas/eventpub-parquet-transformer/src/index.ts new file mode 100644 index 000000000..b310292f4 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/src/index.ts @@ -0,0 +1,7 @@ +import { FirehoseTransformationHandler } from "aws-lambda"; +import transformRecord from "./transform-record"; + +// eslint-disable-next-line import-x/prefer-default-export +export const handler: FirehoseTransformationHandler = async (event) => ({ + records: event.records.map((record) => transformRecord(record)), +}); diff --git a/lambdas/eventpub-parquet-transformer/src/transform-record.ts b/lambdas/eventpub-parquet-transformer/src/transform-record.ts new file mode 100644 index 000000000..b743de50f --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/src/transform-record.ts @@ -0,0 +1,83 @@ +import type { + FirehoseRecordTransformationStatus, + FirehoseTransformationResultRecord, +} from "aws-lambda"; + +const ENVELOPE_FIELDS = [ + "specversion", + "id", + "type", + "plane", + "dataschema", + "dataschemaversion", + "source", + "subject", + "time", + "recordedtime", + "datacontenttype", + "traceparent", + "tracestate", + "partitionkey", + "sampledrate", + "sequence", + "severitytext", +] as const; + +const PROMOTED_DATA_FIELDS = [ + "domainId", + "supplierId", + "groupId", + "campaignId", + "clientId", + "status", +] as const; + +type Json = Record; + +function isObject(value: unknown): value is Json { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function flatten(event: Json): Json { + const data = isObject(event.data) ? event.data : {}; + + const flat: Json = {}; + for (const field of ENVELOPE_FIELDS) { + flat[field] = event[field]; + } + flat.data_json = JSON.stringify(event.data ?? null); + for (const field of PROMOTED_DATA_FIELDS) { + flat[field.toLowerCase()] = data[field]; + } + return flat; +} + +function result( + recordId: string, + status: FirehoseRecordTransformationStatus, + data: string, +): FirehoseTransformationResultRecord { + return { recordId, result: status, data }; +} + +export default function transformRecord(record: { + recordId: string; + data: string; +}): FirehoseTransformationResultRecord { + try { + const event: unknown = JSON.parse( + Buffer.from(record.data, "base64").toString("utf8"), + ); + if (!isObject(event)) { + return result(record.recordId, "ProcessingFailed", record.data); + } + const output = `${JSON.stringify(flatten(event))}\n`; + return result( + record.recordId, + "Ok", + Buffer.from(output, "utf8").toString("base64"), + ); + } catch { + return result(record.recordId, "ProcessingFailed", record.data); + } +} diff --git a/lambdas/eventpub-parquet-transformer/tsconfig.json b/lambdas/eventpub-parquet-transformer/tsconfig.json new file mode 100644 index 000000000..bb8177b74 --- /dev/null +++ b/lambdas/eventpub-parquet-transformer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "esModuleInterop": true + }, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/letter-sla-breaches/common/jest.config.ts b/lambdas/letter-sla-breaches/common/jest.config.ts new file mode 100644 index 000000000..d38a979df --- /dev/null +++ b/lambdas/letter-sla-breaches/common/jest.config.ts @@ -0,0 +1,28 @@ +export default { + preset: "ts-jest", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + coveragePathIgnorePatterns: ["/__tests__/"], + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + testEnvironment: "node", +}; diff --git a/lambdas/letter-sla-breaches/common/package.json b/lambdas/letter-sla-breaches/common/package.json new file mode 100644 index 000000000..3e5758ac4 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "@aws-sdk/client-athena": "^3.1149.0", + "@internal/helpers": "^0.1.0", + "pino": "^10.4.0" + }, + "description": "Common utilities for letter SLA breach lambdas", + "license": "MIT", + "main": "src/index.ts", + "name": "@internal/letter-sla-breaches-common", + "private": true, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts new file mode 100644 index 000000000..932387f95 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena-date-predicate.test.ts @@ -0,0 +1,76 @@ +import buildAthenaDatePredicate from "../athena-date-predicate"; + +describe("buildAthenaDatePredicate", () => { + it("includes only the requested UTC date when no days are subtracted", () => { + expect( + buildAthenaDatePredicate(new Date("2026-10-14T23:59:59.999Z"), 0), + ).toBe("(__year = '2026' AND __month = '10' AND __day IN ('14'))"); + }); + + it("groups consecutive dates in the same month into one partition predicate", () => { + expect( + buildAthenaDatePredicate(new Date("2026-10-14T12:00:00.000Z"), 2), + ).toBe( + "(__year = '2026' AND __month = '10' AND __day IN ('12', '13', '14'))", + ); + }); + + it("creates separate predicates when the date range crosses a month", () => { + expect( + buildAthenaDatePredicate(new Date("2026-11-01T00:00:00.000Z"), 2), + ).toBe( + "(__year = '2026' AND __month = '10' AND __day IN ('30', '31'))\n" + + " OR (__year = '2026' AND __month = '11' AND __day IN ('01'))", + ); + }); + + it("creates separate predicates when the date range crosses a year", () => { + expect( + buildAthenaDatePredicate(new Date("2026-01-01T00:00:00.000Z"), 1), + ).toBe( + "(__year = '2025' AND __month = '12' AND __day IN ('31'))\n" + + " OR (__year = '2026' AND __month = '01' AND __day IN ('01'))", + ); + }); + + it("includes leap days in the date range", () => { + expect( + buildAthenaDatePredicate(new Date("2024-03-01T00:00:00.000Z"), 2), + ).toBe( + "(__year = '2024' AND __month = '02' AND __day IN ('28', '29'))\n" + + " OR (__year = '2024' AND __month = '03' AND __day IN ('01'))", + ); + }); + + it("uses UTC calendar dates regardless of the time component", () => { + expect( + buildAthenaDatePredicate(new Date("2026-10-14T00:30:00+02:00"), 1), + ).toBe("(__year = '2026' AND __month = '10' AND __day IN ('12', '13'))"); + }); + + it("throws when given an invalid date", () => { + expect(() => buildAthenaDatePredicate(new Date(Number.NaN), 0)).toThrow( + new RangeError("date must be valid"), + ); + }); + + it.each([ + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ])( + "throws when daysToSubtract is not a non-negative safe integer (%s)", + (daysToSubtract) => { + expect(() => + buildAthenaDatePredicate( + new Date("2026-10-14T00:00:00.000Z"), + daysToSubtract, + ), + ).toThrow( + new RangeError("daysToSubtract must be a non-negative integer"), + ); + }, + ); +}); diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts new file mode 100644 index 000000000..d33a405a0 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts @@ -0,0 +1,155 @@ +import { + AthenaClient, + GetQueryExecutionCommand, + GetQueryResultsCommand, + StartQueryExecutionCommand, +} from "@aws-sdk/client-athena"; +import runAthenaQuery from "../athena"; +import { type Deps } from "../deps"; + +const query = { queryString: "SELECT ?", executionParameters: ["'a'"] }; + +const deps = { + athenaClient: { send: jest.fn() } as unknown as AthenaClient, + logger: { info: jest.fn(), debug: jest.fn() }, + env: { + ATHENA_WORKGROUP: "workgroup", + ATHENA_DATABASE: "database", + EVENT_CACHE_TABLE_NAME: "event_cache", + }, +} as unknown as Deps; + +const columnInfo = { ColumnInfo: [{ Name: "id" }, { Name: "status" }] }; + +function mockResolvedValues(fn: any, returnValues: any[]) { + for (const value of returnValues) { + (fn as jest.Mock).mockResolvedValueOnce(value); + } +} + +describe("runAthenaQuery", () => { + it("starts the query, polls until complete and returns paginated rows", async () => { + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + { QueryExecution: { Status: { State: "QUEUED" } } }, + { QueryExecution: { Status: { State: "RUNNING" } } }, + { QueryExecution: { Status: { State: "SUCCEEDED" } } }, + { + ResultSet: { + ResultSetMetadata: columnInfo, + Rows: [ + { Data: [{ VarCharValue: "id" }, { VarCharValue: "status" }] }, + { Data: [{ VarCharValue: "1" }, { VarCharValue: "PENDING" }] }, + ], + }, + NextToken: "next", + }, + { + ResultSet: { + ResultSetMetadata: columnInfo, + Rows: [{ Data: [{ VarCharValue: "2" }, {}] }], + }, + }, + ]); + + const rows = await runAthenaQuery(deps, query, { pollIntervalMs: 0 }); + + expect(rows).toEqual([ + { id: "1", status: "PENDING" }, + { id: "2", status: undefined }, + ]); + const commands = (deps.athenaClient.send as jest.Mock).mock.calls.map( + ([command]) => command, + ); + expect(commands[0]).toBeInstanceOf(StartQueryExecutionCommand); + expect(commands[0].input).toEqual({ + QueryString: "SELECT ?", + ExecutionParameters: ["'a'"], + WorkGroup: "workgroup", + QueryExecutionContext: { Database: "database" }, + }); + expect(commands[1]).toBeInstanceOf(GetQueryExecutionCommand); + expect(commands[4]).toBeInstanceOf(GetQueryResultsCommand); + expect(commands[5].input).toEqual({ + QueryExecutionId: "qid", + NextToken: "next", + }); + }); + + it("handles empty result sets and missing metadata", async () => { + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + { QueryExecution: { Status: { State: "SUCCEEDED" } } }, + { + ResultSet: { ResultSetMetadata: { ColumnInfo: [{}] }, Rows: [{}, {}] }, + }, + ]); + + await expect(runAthenaQuery(deps, query)).resolves.toEqual([ + { "": undefined }, + ]); + }); + + it("handles a response with no result set", async () => { + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + { QueryExecution: { Status: { State: "SUCCEEDED" } } }, + {}, + ]); + + await expect(runAthenaQuery(deps, query)).resolves.toEqual([]); + }); + + it("uses the default poll interval when none is given", async () => { + jest.useFakeTimers(); + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + {}, + { QueryExecution: { Status: { State: "SUCCEEDED" } } }, + {}, + ]); + + const promise = runAthenaQuery(deps, query); + await jest.advanceTimersByTimeAsync(1000); + + await expect(promise).resolves.toEqual([]); + jest.useRealTimers(); + }); + + it("throws when no query execution id is returned", async () => { + mockResolvedValues(deps.athenaClient.send, [{}]); + + await expect(runAthenaQuery(deps, query)).rejects.toThrow( + "Athena did not return a query execution id", + ); + }); + + it.each(["FAILED", "CANCELLED"])( + "throws when the query is %s", + async (state) => { + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + { + QueryExecution: { + Status: { State: state, StateChangeReason: "bad query" }, + }, + }, + ]); + + await expect(runAthenaQuery(deps, query)).rejects.toThrow( + `Athena query qid ${state}: bad query`, + ); + }, + ); + + it("throws with a default reason when none is given", async () => { + mockResolvedValues(deps.athenaClient.send, [ + { QueryExecutionId: "qid" }, + { QueryExecution: { Status: { State: "FAILED" } } }, + ]); + + await expect(runAthenaQuery(deps, query)).rejects.toThrow( + "Athena query qid FAILED: no reason given", + ); + }); +}); diff --git a/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts b/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts new file mode 100644 index 000000000..17b74ed1d --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/athena-date-predicate.ts @@ -0,0 +1,45 @@ +export default function buildAthenaDatePredicate( + date: Date, + daysToSubtract: number, +): string { + if (Number.isNaN(date.getTime())) { + throw new RangeError("date must be valid"); + } + + if (!Number.isSafeInteger(daysToSubtract) || daysToSubtract < 0) { + throw new RangeError("daysToSubtract must be a non-negative integer"); + } + + const endDate = new Date(date); + endDate.setUTCHours(0, 0, 0, 0); + + const currentDate = new Date(endDate); + currentDate.setUTCDate(currentDate.getUTCDate() - daysToSubtract); + + const groups: { year: string; month: string; days: string[] }[] = []; + + while (currentDate <= endDate) { + const year = String(currentDate.getUTCFullYear()).padStart(4, "0"); + const month = String(currentDate.getUTCMonth() + 1).padStart(2, "0"); + const day = String(currentDate.getUTCDate()).padStart(2, "0"); + const lastGroup = groups.at(-1); + + if (lastGroup?.year === year && lastGroup.month === month) { + lastGroup.days.push(day); + } else { + groups.push({ year, month, days: [day] }); + } + + currentDate.setUTCDate(currentDate.getUTCDate() + 1); + } + + return groups + .map( + // eslint-disable-next-line sort-destructure-keys/sort-destructure-keys + ({ year, month, days }) => + `(__year = '${year}' AND __month = '${month}' AND __day IN (${days + .map((day) => `'${day}'`) + .join(", ")}))`, + ) + .join("\n OR "); +} diff --git a/lambdas/letter-sla-breaches/common/src/athena.ts b/lambdas/letter-sla-breaches/common/src/athena.ts new file mode 100644 index 000000000..9bd2d58fb --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/athena.ts @@ -0,0 +1,132 @@ +import { + GetQueryExecutionCommand, + GetQueryResultsCommand, + GetQueryResultsCommandOutput, + QueryExecutionState, + StartQueryExecutionCommand, +} from "@aws-sdk/client-athena"; +import { Deps } from "./deps"; + +export type AthenaQuery = { + queryString: string; + executionParameters?: string[]; +}; + +export type AthenaQueryOptions = { + pollIntervalMs?: number; +}; + +export type AthenaRow = Record; + +const DEFAULT_POLL_INTERVAL_MS = 1000; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +async function startQuery(deps: Deps, query: AthenaQuery): Promise { + const { QueryExecutionId } = await deps.athenaClient.send( + new StartQueryExecutionCommand({ + QueryString: query.queryString, + ExecutionParameters: query.executionParameters, + WorkGroup: deps.env.ATHENA_WORKGROUP, + QueryExecutionContext: { Database: deps.env.ATHENA_DATABASE }, + }), + ); + + if (!QueryExecutionId) { + throw new Error("Athena did not return a query execution id"); + } + + return QueryExecutionId; +} + +async function waitForQuery( + deps: Deps, + queryExecutionId: string, + pollIntervalMs: number, +): Promise { + while (true) { + const { QueryExecution } = await deps.athenaClient.send( + new GetQueryExecutionCommand({ QueryExecutionId: queryExecutionId }), + ); + const state = QueryExecution?.Status?.State; + + switch (state) { + case QueryExecutionState.SUCCEEDED: { + return; + } + case QueryExecutionState.FAILED: + case QueryExecutionState.CANCELLED: { + throw new Error( + `Athena query ${queryExecutionId} ${state}: ${QueryExecution?.Status?.StateChangeReason ?? "no reason given"}`, + ); + } + default: { + deps.logger.debug({ queryExecutionId, state }, "Waiting for query"); + await sleep(pollIntervalMs); + } + } + } +} + +function toRows( + page: GetQueryResultsCommandOutput, + isFirstPage: boolean, +): AthenaRow[] { + const columns = + page.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((c) => c.Name ?? "") ?? + []; + // For SELECT queries the first row of the first page contains the column headers. + const rows = (page.ResultSet?.Rows ?? []).slice(isFirstPage ? 1 : 0); + + return rows.map((row) => + Object.fromEntries( + columns.map((column, index) => [ + column, + row.Data?.at(index)?.VarCharValue, + ]), + ), + ); +} + +async function getResults( + deps: Deps, + queryExecutionId: string, +): Promise { + const results: AthenaRow[] = []; + let nextToken: string | undefined; + let isFirstPage = true; + + do { + const page = await deps.athenaClient.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + }), + ); + results.push(...toRows(page, isFirstPage)); + nextToken = page.NextToken; + isFirstPage = false; + } while (nextToken); + + return results; +} + +export default async function runAthenaQuery( + deps: Deps, + query: AthenaQuery, + options: AthenaQueryOptions = {}, +): Promise { + const queryExecutionId = await startQuery(deps, query); + deps.logger.info({ description: "Started Athena query", query }); + + await waitForQuery( + deps, + queryExecutionId, + options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + ); + + return getResults(deps, queryExecutionId); +} diff --git a/lambdas/letter-sla-breaches/common/src/deps.ts b/lambdas/letter-sla-breaches/common/src/deps.ts new file mode 100644 index 000000000..fb801c886 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/deps.ts @@ -0,0 +1,25 @@ +import { AthenaClient } from "@aws-sdk/client-athena"; +import { createLogger } from "@internal/helpers"; +import { Logger } from "pino"; + +export type Deps = { + athenaClient: AthenaClient; + logger: Logger; + env: { + ATHENA_WORKGROUP: string; + ATHENA_DATABASE: string; + EVENT_CACHE_TABLE_NAME: string; + }; +}; + +export default function createDependenciesContainer(): Deps { + return { + athenaClient: new AthenaClient(), + logger: createLogger({ logLevel: process.env.PINO_LOG_LEVEL }), + env: { + ATHENA_WORKGROUP: process.env.ATHENA_WORKGROUP!, + ATHENA_DATABASE: process.env.ATHENA_DATABASE!, + EVENT_CACHE_TABLE_NAME: process.env.EVENT_CACHE_TABLE_NAME!, + }, + }; +} diff --git a/lambdas/letter-sla-breaches/common/src/index.ts b/lambdas/letter-sla-breaches/common/src/index.ts new file mode 100644 index 000000000..27aaea112 --- /dev/null +++ b/lambdas/letter-sla-breaches/common/src/index.ts @@ -0,0 +1,5 @@ +export { default as runAthenaQuery } from "./athena"; +export { default as createDependenciesContainer } from "./deps"; +export { default as buildAthenaDatePredicate } from "./athena-date-predicate"; +export type { AthenaQuery, AthenaQueryOptions, AthenaRow } from "./athena"; +export type { Deps } from "./deps"; diff --git a/lambdas/letter-sla-breaches/common/tsconfig.json b/lambdas/letter-sla-breaches/common/tsconfig.json new file mode 100644 index 000000000..4a11b255a --- /dev/null +++ b/lambdas/letter-sla-breaches/common/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "ignoreDeprecations": "5.0" + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore b/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore new file mode 100644 index 000000000..fc5a7859a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/.gitignore @@ -0,0 +1,5 @@ +coverage +node_modules +dist +.reports +.aws-sam diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts new file mode 100644 index 000000000..ec5a312bb --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/jest.config.ts @@ -0,0 +1,46 @@ +export const baseJestConfig = { + preset: "ts-jest", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + testEnvironment: "node", +}; + +export default baseJestConfig; diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json b/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json new file mode 100644 index 000000000..127c212c3 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/package.json @@ -0,0 +1,15 @@ +{ + "dependencies": { + "esbuild": "0.28.2" + }, + "name": "nhs-notify-supplier-api-populate-sameday-breaches", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts new file mode 100644 index 000000000..50492a04a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/__tests__/populate-sameday-breaches.test.ts @@ -0,0 +1,12 @@ +import populateSamedayBreaches from "../handler/populate-sameday-breaches"; + +describe("populateSamedayBreaches", () => { + it("returns a success result", async () => { + const result = await populateSamedayBreaches({ date: "2026-10-08" }); + + expect(result).toEqual({ + status: "SUCCESS", + message: "populate-sameday-breaches invoked successfully", + }); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts new file mode 100644 index 000000000..f63d70492 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/handler/populate-sameday-breaches.ts @@ -0,0 +1,18 @@ +export type PopulateSamedayBreachesEvent = { + date: string; +}; + +export type PopulateSamedayBreachesResult = { + status: "SUCCESS"; + message: string; +}; + +// Placeholder: cache population is not yet implemented. +export default async function populateSamedayBreaches( + _event: PopulateSamedayBreachesEvent, +): Promise { + return { + status: "SUCCESS", + message: "populate-sameday-breaches invoked successfully", + }; +} diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts new file mode 100644 index 000000000..3143567ed --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import-x/prefer-default-export +export { default as populateSamedayBreachesHandler } from "./handler/populate-sameday-breaches"; diff --git a/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json b/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json new file mode 100644 index 000000000..f4059d5e6 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-sameday-breaches/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "esModuleInterop": true + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore b/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore new file mode 100644 index 000000000..fc5a7859a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/.gitignore @@ -0,0 +1,5 @@ +coverage +node_modules +dist +.reports +.aws-sam diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts new file mode 100644 index 000000000..ec5a312bb --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/jest.config.ts @@ -0,0 +1,46 @@ +export const baseJestConfig = { + preset: "ts-jest", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + testEnvironment: "node", +}; + +export default baseJestConfig; diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json new file mode 100644 index 000000000..e92bbdc61 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "@internal/letter-sla-breaches-common": "*", + "esbuild": "0.28.2" + }, + "devDependencies": { + "@internal/sql-test-helpers": "*" + }, + "name": "nhs-notify-supplier-api-populate-standard-breaches", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts new file mode 100644 index 000000000..d331ed329 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts @@ -0,0 +1,56 @@ +import { + type Deps, + runAthenaQuery, +} from "@internal/letter-sla-breaches-common"; +import createHandler from "../handler/populate-standard-breaches"; + +jest.mock("@internal/letter-sla-breaches-common"); + +const makeDeps = () => + ({ + logger: { info: jest.fn(), error: jest.fn() }, + env: { EVENT_CACHE_TABLE_NAME: "event_cache" }, + }) as unknown as Deps; + +describe("populateStandardBreaches", () => { + it("runs the standard breaches query and returns success", async () => { + const deps = makeDeps(); + jest.mocked(runAthenaQuery).mockResolvedValueOnce([{ breach_count: "1" }]); + + const result = await createHandler(deps)({ date: "2026-10-08" }); + + expect(runAthenaQuery).toHaveBeenCalled(); + expect(result).toEqual({ + status: "SUCCESS", + breachCounts: JSON.stringify([{ breach_count: "1" }]), + }); + }); + + it("runs the standard breaches query and returns failure", async () => { + const deps = makeDeps(); + jest.mocked(runAthenaQuery).mockRejectedValueOnce(new Error("boom")); + + const result = await createHandler(deps)({ date: "2026-10-08" }); + + expect(runAthenaQuery).toHaveBeenCalled(); + expect(result).toEqual({ + failureReason: "boom", + status: "FAILURE", + breachCounts: "-", + }); + }); + + it("runs the standard breaches query and returns failure when not an error", async () => { + const deps = makeDeps(); + jest.mocked(runAthenaQuery).mockRejectedValueOnce("boom"); + + const result = await createHandler(deps)({ date: "2026-10-08" }); + + expect(runAthenaQuery).toHaveBeenCalled(); + expect(result).toEqual({ + failureReason: "boom", + status: "FAILURE", + breachCounts: "-", + }); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts new file mode 100644 index 000000000..e4af0d4f1 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts @@ -0,0 +1,204 @@ +import { + TrinoContext, + runParameterisedQueryOnTrino, + runTrinoStatement, + setupTrinoContainer, +} from "@internal/sql-test-helpers"; +import buildStandardBreachesQuery from "../handler/standard-breaches-query"; + +type LetterEvent = { + domainid: string; + supplierid: string; + status: string; + time: string; +}; + +const TABLE_NAME = "event_cache"; + +async function insertEvents(context: TrinoContext, events: LetterEvent[]) { + const values = events + .map(({ domainid, status, supplierid, time }) => { + const [year, month, day] = time.slice(0, 10).split("-"); + return `('${domainid}', '${supplierid}', '${status}', '${time}', '${year}', '${month}', '${day}')`; + }) + .join(",\n"); + + await runTrinoStatement( + context, + `INSERT INTO ${TABLE_NAME} VALUES ${values}`, + ); +} + +function sortBySupplier(rows: Record[]) { + return rows.toSorted((a, b) => + (a.supplierid ?? "").localeCompare(b.supplierid ?? ""), + ); +} + +describe("buildStandardBreachesQuery", () => { + let context: TrinoContext; + + beforeAll(async () => { + context = await setupTrinoContainer(); + }, 180_000); + + afterAll(async () => { + await context?.container.stop(); + }); + + beforeEach(async () => { + await runTrinoStatement( + context, + `CREATE TABLE ${TABLE_NAME} ( + domainid VARCHAR, + supplierid VARCHAR, + status VARCHAR, + time VARCHAR, + __year VARCHAR, + __month VARCHAR, + __day VARCHAR + )`, + ); + }); + + afterEach(async () => { + await runTrinoStatement(context, `DROP TABLE IF EXISTS ${TABLE_NAME}`); + }); + + it("counts letters pending for more than 48 hours without a completed status, per supplier", async () => { + // Wednesday + const now = new Date("2026-10-14T12:00:00.000Z"); + + await insertEvents(context, [ + // Breach: pending for > 48 hours, never completed + { + domainid: "letter-1", + supplierid: "supplier-a", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + // Not a breach: completed + { + domainid: "letter-2", + supplierid: "supplier-a", + status: "PENDING", + time: "2026-10-11T10:00:00.000Z", + }, + { + domainid: "letter-2", + supplierid: "supplier-a", + status: "DISPATCHED", + time: "2026-10-13T10:00:00.000Z", + }, + // Not a breach: pending for < 48 hours + { + domainid: "letter-3", + supplierid: "supplier-a", + status: "PENDING", + time: "2026-10-13T10:00:00.000Z", + }, + // Breach: a non-terminal status does not complete the letter + { + domainid: "letter-4", + supplierid: "supplier-b", + status: "PENDING", + time: "2026-10-10T09:00:00.000Z", + }, + { + domainid: "letter-4", + supplierid: "supplier-b", + status: "ACCEPTED", + time: "2026-10-10T10:00:00.000Z", + }, + // Breach: first PENDING event is used, even if repeated later + { + domainid: "letter-5", + supplierid: "supplier-b", + status: "PENDING", + time: "2026-10-12T11:00:00.000Z", + }, + { + domainid: "letter-5", + supplierid: "supplier-b", + status: "PENDING", + time: "2026-10-14T11:00:00.000Z", + }, + // Not a breach: each terminal status completes the letter + ...["REJECTED", "FAILED", "CANCELLED", "FORWARDED"].flatMap( + (status, i) => [ + { + domainid: `letter-terminal-${i}`, + supplierid: "supplier-b", + status: "PENDING", + time: "2026-10-10T10:00:00.000Z", + }, + { + domainid: `letter-terminal-${i}`, + supplierid: "supplier-b", + status, + time: "2026-10-11T10:00:00.000Z", + }, + ], + ), + // Breaching, but ignored: before the 14-day window + { + domainid: "letter-6", + supplierid: "supplier-c", + status: "PENDING", + time: "2026-09-01T10:00:00.000Z", + }, + ]); + + const rows = await runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery(TABLE_NAME, now, 14), + ); + + expect(sortBySupplier(rows)).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + { supplierid: "supplier-b", breach_count: "2" }, + ]); + }); + + it("skips non-working days", async () => { + await insertEvents(context, [ + // Received Thursday - won't breach until Monday + { + domainid: "letter-1", + supplierid: "supplier-a", + status: "PENDING", + time: "2026-10-15T10:00:00.000Z", + }, + // Received Friday - won't breach until Tuesday + { + domainid: "letter-2", + supplierid: "supplier-a", + status: "PENDING", + time: "2026-10-16T10:00:00.000Z", + }, + ]); + + const [rowsOnSaturday, rowsOnSunday, rowsOnMonday, rowsOnTuesday] = + await Promise.all( + [ + new Date("2026-10-17T22:00:00.000Z"), + new Date("2026-10-18T22:00:00.000Z"), + new Date("2026-10-19T22:00:00.000Z"), + new Date("2026-10-20T22:00:00.000Z"), + ].map(async (day) => + runParameterisedQueryOnTrino( + context, + buildStandardBreachesQuery(TABLE_NAME, day, 14), + ), + ), + ); + expect(rowsOnSaturday).toHaveLength(0); + expect(rowsOnSunday).toHaveLength(0); + expect(rowsOnMonday).toEqual([ + { supplierid: "supplier-a", breach_count: "1" }, + ]); + expect(rowsOnTuesday).toEqual([ + { supplierid: "supplier-a", breach_count: "2" }, + ]); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts new file mode 100644 index 000000000..5ffdf4e20 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts @@ -0,0 +1,50 @@ +import { + type Deps, + runAthenaQuery, +} from "@internal/letter-sla-breaches-common"; +import buildStandardBreachesQuery from "./standard-breaches-query"; + +export type PopulateStandardBreachesEvent = { + date: string; +}; + +export type PopulateStandardBreachesResult = { + status: "SUCCESS" | "FAILURE"; + failureReason?: string; + breachCounts: string; +}; + +export default function createHandler(deps: Deps) { + return async ( + event: PopulateStandardBreachesEvent, + ): Promise => { + try { + const sql = buildStandardBreachesQuery( + deps.env.EVENT_CACHE_TABLE_NAME, + new Date(event.date), + 14, + ); + const rows = await runAthenaQuery(deps, sql); + + // TODO: CCM-24041 process the query results to populate standard breaches. + deps.logger.info({ + description: "Standard breaches query completed", + date: event.date, + breachCounts: JSON.stringify(rows), + }); + + return { status: "SUCCESS", breachCounts: JSON.stringify(rows) }; + } catch (error) { + deps.logger.error({ + description: "Failed to populate standard breaches", + date: event.date, + error, + }); + return { + status: "FAILURE", + failureReason: error instanceof Error ? error.message : String(error), + breachCounts: "-", + }; + } + }; +} diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts new file mode 100644 index 000000000..79c6aa462 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts @@ -0,0 +1,43 @@ +import { + type AthenaQuery, + buildAthenaDatePredicate, +} from "@internal/letter-sla-breaches-common"; + +export default function buildStandardBreachesQuery( + tableName: string, + now: Date, + daysHistory: number, +): AthenaQuery { + return { + queryString: `WITH parameters AS ( + SELECT from_iso8601_timestamp(?) AS now +), +recent_letter_events AS ( + SELECT domainid, supplierid, status, from_iso8601_timestamp(time) AS event_time + FROM ${tableName} + WHERE ${buildAthenaDatePredicate(now, daysHistory)} +), +allocated_letters AS ( + SELECT domainid, supplierid + FROM recent_letter_events + WHERE status = 'PENDING' + GROUP BY domainid, supplierid + HAVING min(event_time) < (SELECT now FROM parameters) - + CASE WHEN day_of_week(min(event_time) AT TIME ZONE 'Europe/London') IN (4, 5, 6) THEN INTERVAL '96' HOUR ELSE INTERVAL '48' HOUR END +), +completed_letters AS ( + SELECT domainid, supplierid + FROM recent_letter_events + WHERE status in ('REJECTED', 'FAILED', 'CANCELLED', 'FORWARDED', 'DISPATCHED') + GROUP BY domainid, supplierid +) +SELECT supplierid, count(*) AS breach_count +FROM ( + SELECT al.domainid, al.supplierid + FROM allocated_letters al + LEFT JOIN completed_letters cl ON al.domainid = cl.domainid WHERE cl.domainid IS NULL +) +GROUP BY supplierid`, + executionParameters: [`'${now.toISOString()}'`], + }; +} diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts new file mode 100644 index 000000000..f52283ddb --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts @@ -0,0 +1,7 @@ +import { createDependenciesContainer } from "@internal/letter-sla-breaches-common"; +import createHandler from "./handler/populate-standard-breaches"; + +// eslint-disable-next-line import-x/prefer-default-export +export const populateStandardBreachesHandler = createHandler( + createDependenciesContainer(), +); diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json b/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json new file mode 100644 index 000000000..4a11b255a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-standard-breaches/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "ignoreDeprecations": "5.0" + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore b/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore new file mode 100644 index 000000000..fc5a7859a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/.gitignore @@ -0,0 +1,5 @@ +coverage +node_modules +dist +.reports +.aws-sam diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts new file mode 100644 index 000000000..ec5a312bb --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/jest.config.ts @@ -0,0 +1,46 @@ +export const baseJestConfig = { + preset: "ts-jest", + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + + clearMocks: true, + collectCoverage: true, + coverageDirectory: "./.reports/unit/coverage", + coverageProvider: "babel", + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: -10, + }, + }, + + coveragePathIgnorePatterns: ["/__tests__/"], + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + + reporters: [ + "default", + [ + "jest-html-reporter", + { + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", + includeFailureMsg: true, + }, + ], + ], + + testEnvironment: "node", +}; + +export default baseJestConfig; diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json new file mode 100644 index 000000000..5667e2485 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json @@ -0,0 +1,22 @@ +{ + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.1138.0", + "@aws-sdk/client-s3": "^3.1138.0", + "@aws-sdk/lib-dynamodb": "^3.1138.0", + "@internal/datastore": "^0.1.0", + "@internal/helpers": "^0.1.0", + "esbuild": "0.28.2", + "pino": "^10.3.1", + "zod": "^4.6.5" + }, + "name": "nhs-notify-supplier-api-populate-supplier-config-cache", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts new file mode 100644 index 000000000..2d68709f7 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/entity-cache.test.ts @@ -0,0 +1,95 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import populateEntityCache, { EntityCacheDeps } from "../handler/entity-cache"; + +function makeDeps(ddbSend: jest.Mock, s3Send: jest.Mock): EntityCacheDeps { + return { + ddbClient: { send: ddbSend } as unknown as DynamoDBDocumentClient, + s3Client: { send: s3Send } as unknown as S3Client, + logger: { info: jest.fn() } as unknown as EntityCacheDeps["logger"], + env: { + SUPPLIER_CONFIG_TABLE_NAME: "config-table", + EVENT_CACHE_BUCKET_NAME: "cache-bucket", + }, + }; +} + +describe("populateEntityCache", () => { + it("queries all pages for the entity and writes them to S3", async () => { + const ddbSend = jest + .fn() + .mockResolvedValueOnce({ + Items: [{ pk: "ENTITY#supplier", sk: "ID#1" }], + LastEvaluatedKey: { pk: "ENTITY#supplier", sk: "ID#1" }, + }) + .mockResolvedValueOnce({ + Items: [{ pk: "ENTITY#supplier", sk: "ID#2" }], + }); + const s3Send = jest.fn().mockResolvedValue({}); + const deps = makeDeps(ddbSend, s3Send); + + const count = await populateEntityCache(deps, "supplier"); + + expect(count).toBe(2); + expect(ddbSend).toHaveBeenCalledTimes(2); + const firstQuery = ddbSend.mock.calls[0][0] as QueryCommand; + expect(firstQuery.input).toEqual({ + TableName: "config-table", + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": "ENTITY#supplier" }, + ExclusiveStartKey: undefined, + }); + const secondQuery = ddbSend.mock.calls[1][0] as QueryCommand; + expect(secondQuery.input.ExclusiveStartKey).toEqual({ + pk: "ENTITY#supplier", + sk: "ID#1", + }); + + const put = s3Send.mock.calls[0][0] as PutObjectCommand; + expect(put.input.Bucket).toBe("cache-bucket"); + expect(put.input.Key).toBe("supplier_config_cache/supplier/records.json"); + expect(deps.logger.info).toHaveBeenCalledWith( + { entityType: "supplier" }, + "Loading supplier config entity", + ); + expect(deps.logger.info).toHaveBeenCalledWith( + { entityType: "supplier", itemCount: 2, hasItems: true }, + "Loaded supplier config entity", + ); + expect(deps.logger.info).toHaveBeenCalledWith( + { + entityType: "supplier", + itemCount: 2, + bucket: "cache-bucket", + key: "supplier_config_cache/supplier/records.json", + s3Uri: "s3://cache-bucket/supplier_config_cache/supplier/records.json", + }, + "Created supplier config cache file", + ); + expect( + (put.input.Body as string).split("\n").map((line) => JSON.parse(line)), + ).toEqual([ + { pk: "ENTITY#supplier", sk: "ID#1" }, + { pk: "ENTITY#supplier", sk: "ID#2" }, + ]); + }); + + it("writes an empty file when no rows are returned", async () => { + const ddbSend = jest.fn().mockResolvedValue({}); + const s3Send = jest.fn().mockResolvedValue({}); + const deps = makeDeps(ddbSend, s3Send); + + const count = await populateEntityCache(deps, "volume-group"); + + expect(count).toBe(0); + const put = s3Send.mock.calls[0][0] as PutObjectCommand; + expect(put.input.Key).toBe( + "supplier_config_cache/volume-group/records.json", + ); + expect(put.input.Body).toBe(""); + expect(deps.logger.info).toHaveBeenCalledWith( + { entityType: "volume-group", itemCount: 0, hasItems: false }, + "Loaded supplier config entity", + ); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts new file mode 100644 index 000000000..ee6aafe5c --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/env.test.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* Allow require imports to enable re-import of modules */ + +import { ZodError } from "zod"; + +describe("lambdaEnv", () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("loads required and optional environment variables", () => { + process.env.SUPPLIER_CONFIG_TABLE_NAME = "supplier-config-table"; + process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket"; + process.env.PINO_LOG_LEVEL = "debug"; + + const { envVars } = require("../handler/env"); + + expect(envVars).toEqual({ + SUPPLIER_CONFIG_TABLE_NAME: "supplier-config-table", + EVENT_CACHE_BUCKET_NAME: "event-cache-bucket", + PINO_LOG_LEVEL: "debug", + }); + }); + + it("throws when a required environment variable is missing", () => { + process.env.SUPPLIER_CONFIG_TABLE_NAME = undefined; + process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket"; + + expect(() => require("../handler/env")).toThrow(ZodError); + }); + + it("allows the optional log level to be unset", () => { + process.env.SUPPLIER_CONFIG_TABLE_NAME = "supplier-config-table"; + process.env.EVENT_CACHE_BUCKET_NAME = "event-cache-bucket"; + process.env.PINO_LOG_LEVEL = undefined; + + const { envVars } = require("../handler/env"); + + expect(envVars.PINO_LOG_LEVEL).toBeUndefined(); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts new file mode 100644 index 000000000..047694df6 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/__tests__/populate-supplier-config-cache.test.ts @@ -0,0 +1,66 @@ +import { $SupplierConfigEntity } from "@internal/datastore"; +import populateEntityCache, { EntityCacheDeps } from "../handler/entity-cache"; +import createHandler from "../handler/populate-supplier-config-cache"; + +jest.mock("@internal/datastore", () => ({ + $SupplierConfigEntity: { + options: [ + "letter-variant", + "volume-group", + "supplier-allocation", + "supplier", + "pack-specification", + "supplier-pack", + ], + }, +})); +jest.mock("../handler/entity-cache"); + +describe("populateSupplierConfigCache", () => { + it("populates the cache once for each entity type and returns success", async () => { + const deps = { logger: { error: jest.fn() } } as unknown as EntityCacheDeps; + const entityTypes = $SupplierConfigEntity.options; + jest.mocked(populateEntityCache).mockReset(); + for (const [index] of entityTypes.entries()) { + jest.mocked(populateEntityCache).mockResolvedValueOnce(index + 1); + } + const handler = createHandler(deps); + + const result = await handler(); + + expect(populateEntityCache).toHaveBeenCalledTimes(entityTypes.length); + for (const entityType of entityTypes) { + expect(populateEntityCache).toHaveBeenCalledWith(deps, entityType); + } + expect(result).toEqual({ + status: "SUCCESS", + }); + }); + + it("returns failure when an entity fails", async () => { + const deps = { logger: { error: jest.fn() } } as unknown as EntityCacheDeps; + const entityTypes = $SupplierConfigEntity.options; + jest.mocked(populateEntityCache).mockReset(); + for (const [index] of entityTypes.entries()) { + if (index === 1) { + jest + .mocked(populateEntityCache) + .mockRejectedValueOnce(new Error("query failed")); + } else { + jest.mocked(populateEntityCache).mockResolvedValueOnce(index + 1); + } + } + const handler = createHandler(deps); + + const result = await handler(); + + expect(result).toEqual({ + status: "FAILURE", + failureReason: `Failed to populate supplier config cache for: ${entityTypes[1]}`, + }); + expect(deps.logger.error).toHaveBeenCalledWith( + { entityType: entityTypes[1], error: expect.any(Error) }, + "Failed to populate supplier config entity cache", + ); + }); +}); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts new file mode 100644 index 000000000..9be803265 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/deps.ts @@ -0,0 +1,18 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { createLogger } from "@internal/helpers"; +import { EntityCacheDeps } from "./entity-cache"; +import { envVars } from "./env"; + +export default function createDependenciesContainer(): EntityCacheDeps { + return { + ddbClient: DynamoDBDocumentClient.from(new DynamoDBClient()), + s3Client: new S3Client(), + logger: createLogger({ logLevel: envVars.PINO_LOG_LEVEL }), + env: { + SUPPLIER_CONFIG_TABLE_NAME: envVars.SUPPLIER_CONFIG_TABLE_NAME, + EVENT_CACHE_BUCKET_NAME: envVars.EVENT_CACHE_BUCKET_NAME, + }, + }; +} diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts new file mode 100644 index 000000000..eb4333e9a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/entity-cache.ts @@ -0,0 +1,70 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + DynamoDBDocumentClient, + QueryCommand, + QueryCommandOutput, +} from "@aws-sdk/lib-dynamodb"; +import { Logger } from "pino"; + +export type EntityCacheDeps = { + ddbClient: DynamoDBDocumentClient; + s3Client: S3Client; + logger: Logger; + env: { + SUPPLIER_CONFIG_TABLE_NAME: string; + EVENT_CACHE_BUCKET_NAME: string; + }; +}; + +export const CACHE_FOLDER = "supplier_config_cache"; + +export default async function populateEntityCache( + deps: EntityCacheDeps, + entityType: string, +): Promise { + deps.logger.info({ entityType }, "Loading supplier config entity"); + + const items: Record[] = []; + let exclusiveStartKey: QueryCommandOutput["LastEvaluatedKey"]; + + do { + const page: QueryCommandOutput = await deps.ddbClient.send( + new QueryCommand({ + TableName: deps.env.SUPPLIER_CONFIG_TABLE_NAME, + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": `ENTITY#${entityType}` }, + ExclusiveStartKey: exclusiveStartKey, + }), + ); + items.push(...(page.Items ?? [])); + exclusiveStartKey = page.LastEvaluatedKey; + } while (exclusiveStartKey); + + deps.logger.info( + { entityType, itemCount: items.length, hasItems: items.length > 0 }, + "Loaded supplier config entity", + ); + + const key = `${CACHE_FOLDER}/${entityType}/records.json`; + await deps.s3Client.send( + new PutObjectCommand({ + Bucket: deps.env.EVENT_CACHE_BUCKET_NAME, + Key: key, + Body: items.map((item) => JSON.stringify(item)).join("\n"), + ContentType: "application/json", + }), + ); + + deps.logger.info( + { + entityType, + itemCount: items.length, + bucket: deps.env.EVENT_CACHE_BUCKET_NAME, + key, + s3Uri: `s3://${deps.env.EVENT_CACHE_BUCKET_NAME}/${key}`, + }, + "Created supplier config cache file", + ); + + return items.length; +} diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts new file mode 100644 index 000000000..03be4f276 --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/env.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +const EnvVarsSchema = z.object({ + SUPPLIER_CONFIG_TABLE_NAME: z.string(), + EVENT_CACHE_BUCKET_NAME: z.string(), + PINO_LOG_LEVEL: z.string().optional(), +}); + +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts new file mode 100644 index 000000000..8ce432a6c --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/handler/populate-supplier-config-cache.ts @@ -0,0 +1,45 @@ +import { $SupplierConfigEntity } from "@internal/datastore"; +import populateEntityCache, { EntityCacheDeps } from "./entity-cache"; + +export type PopulateSupplierConfigCacheResult = { + status: "SUCCESS" | "FAILURE"; + failureReason?: string; +}; + +const ENTITY_TYPES = $SupplierConfigEntity.options; + +export default function createHandler(deps: EntityCacheDeps) { + return async (): Promise => { + const results = await Promise.all( + ENTITY_TYPES.map(async (entityType) => { + try { + return { + entity: entityType, + recordsRetrieved: await populateEntityCache(deps, entityType), + failed: false, + }; + } catch (error) { + deps.logger.error( + { entityType, error }, + "Failed to populate supplier config entity cache", + ); + return { entity: entityType, recordsRetrieved: 0, failed: true }; + } + }), + ); + const failedEntities = results + .filter((result) => result.failed) + .map((result) => result.entity); + + if (failedEntities.length > 0) { + return { + status: "FAILURE", + failureReason: `Failed to populate supplier config cache for: ${failedEntities.join(", ")}`, + }; + } + + return { + status: "SUCCESS", + }; + }; +} diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts new file mode 100644 index 000000000..fd06c3b3f --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/src/index.ts @@ -0,0 +1,7 @@ +import createDependenciesContainer from "./handler/deps"; +import createHandler from "./handler/populate-supplier-config-cache"; + +// eslint-disable-next-line import-x/prefer-default-export +export const populateSupplierConfigCacheHandler = createHandler( + createDependenciesContainer(), +); diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json new file mode 100644 index 000000000..4a11b255a --- /dev/null +++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "ignoreDeprecations": "5.0" + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index cae5e3cea..9037c20bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "docs", "internal/*", "lambdas/*", + "lambdas/letter-sla-breaches/*", "pact-contracts", "scripts/utilities/*", "tests", @@ -155,30 +156,6 @@ "node": ">=20.0.0" } }, - "internal/datastore/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "internal/datastore/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "internal/datastore/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -196,33 +173,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "internal/datastore/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "internal/datastore/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "internal/datastore/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { "version": "1.1.1", "dependencies": { @@ -241,7 +191,7 @@ }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.21", + "version": "1.0.23", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^1.0.2", @@ -307,6 +257,14 @@ "zod": "^4.6.5" } }, + "internal/sql-test-helpers": { + "name": "@internal/sql-test-helpers", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "testcontainers": "^12.2.0" + } + }, "lambdas/api-handler": { "name": "nhs-notify-supplier-api-handler", "version": "0.0.1", @@ -365,30 +323,6 @@ "node": ">=20.0.0" } }, - "lambdas/api-handler/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/api-handler/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/api-handler/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -406,20 +340,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "lambdas/api-handler/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/api-handler/node_modules/@aws-sdk/middleware-sdk-sqs": { "version": "3.972.43", "license": "Apache-2.0", @@ -433,19 +353,6 @@ "node": ">=20.0.0" } }, - "lambdas/api-handler/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "lambdas/authorizer": { "name": "nhs-notify-supplier-authorizer", "version": "0.0.1", @@ -480,30 +387,6 @@ "node": ">=20.0.0" } }, - "lambdas/authorizer/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/authorizer/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/authorizer/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -521,132 +404,120 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "lambdas/authorizer/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", + "lambdas/eventpub-parquet-transformer": { + "name": "nhs-notify-supplier-api-eventpub-parquet-transformer", + "version": "0.0.1", "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "aws-lambda": "^1.0.7", + "esbuild": "0.28.2" } }, - "lambdas/authorizer/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", + "lambdas/letter-sla-breaches/common": { + "name": "@internal/letter-sla-breaches-common", + "version": "0.0.1", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "@aws-sdk/client-athena": "^3.1149.0", + "@internal/helpers": "^0.1.0", + "pino": "^10.4.0" + } + }, + "lambdas/letter-sla-breaches/common/node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" + "bin": { + "pino": "bin.js" } }, - "lambdas/letter-updates-transformer": { - "name": "nhs-notify-supplier-api-letter-updates-transformer", - "version": "0.0.1", + "lambdas/letter-sla-breaches/common/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", "dependencies": { - "@aws-sdk/client-sns": "^3.1138.0", - "@aws-sdk/util-dynamodb": "^3.996.9", - "@internal/datastore": "^0.1.0", - "@internal/event-builders": "*", - "@internal/helpers": "^0.1.0", - "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", - "aws-embedded-metrics": "^4.2.1", - "aws-lambda": "^1.0.7", - "esbuild": "0.28.2", - "pino": "^10.3.1", - "zod": "^4.6.5" + "split2": "^4.0.0" } }, - "lambdas/letter-updates-transformer/node_modules/@aws-sdk/client-dynamodb": { - "version": "3.1146.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1146.0.tgz", - "integrity": "sha512-3Mc8YkSu2KlgNNSuspaW+Fo0pTwX+fLYlC5ovH3yX3OoDg2RPtp3Py8KuQJr0FDlQj1tHw0K3+AtaH4NXlI3aQ==", - "license": "Apache-2.0", - "peer": true, + "lambdas/letter-sla-breaches/common/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "lambdas/letter-sla-breaches/common/node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/credential-provider-node": "^3.972.84", - "@aws-sdk/dynamodb-codec": "^3.973.46", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.31", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/fetch-http-handler": "^5.8.0", - "@smithy/node-http-handler": "^4.12.1", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" + "real-require": "^1.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=20" } }, - "lambdas/letter-updates-transformer/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz", - "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==", - "license": "Apache-2.0", - "peer": true, + "lambdas/letter-sla-breaches/populate-sameday-breaches": { + "name": "nhs-notify-supplier-api-populate-sameday-breaches", + "version": "0.0.1", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "esbuild": "0.28.2" } }, - "lambdas/letter-updates-transformer/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz", - "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==", - "license": "Apache-2.0", - "peer": true, + "lambdas/letter-sla-breaches/populate-standard-breaches": { + "name": "nhs-notify-supplier-api-populate-standard-breaches", + "version": "0.0.1", "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" + "@internal/letter-sla-breaches-common": "*", + "esbuild": "0.28.2" }, - "engines": { - "node": ">=20.0.0" + "devDependencies": { + "@internal/sql-test-helpers": "*" } }, - "lambdas/letter-updates-transformer/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz", - "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==", - "license": "Apache-2.0", - "peer": true, + "lambdas/letter-sla-breaches/populate-supplier-config-cache": { + "name": "nhs-notify-supplier-api-populate-supplier-config-cache", + "version": "0.0.1", "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "@aws-sdk/client-dynamodb": "^3.1138.0", + "@aws-sdk/client-s3": "^3.1138.0", + "@aws-sdk/lib-dynamodb": "^3.1138.0", + "@internal/datastore": "^0.1.0", + "@internal/helpers": "^0.1.0", + "esbuild": "0.28.2", + "pino": "^10.3.1", + "zod": "^4.6.5" } }, - "lambdas/letter-updates-transformer/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz", - "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==", - "license": "Apache-2.0", + "lambdas/letter-updates-transformer": { + "name": "nhs-notify-supplier-api-letter-updates-transformer", + "version": "0.0.1", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" + "@aws-sdk/client-sns": "^3.1138.0", + "@aws-sdk/util-dynamodb": "^3.996.9", + "@internal/datastore": "^0.1.0", + "@internal/event-builders": "*", + "@internal/helpers": "^0.1.0", + "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-embedded-metrics": "^4.2.1", + "aws-lambda": "^1.0.7", + "esbuild": "0.28.2", + "pino": "^10.3.1", + "zod": "^4.6.5" } }, "lambdas/mi-updates-transformer": { @@ -665,90 +536,6 @@ "zod": "^4.6.5" } }, - "lambdas/mi-updates-transformer/node_modules/@aws-sdk/client-dynamodb": { - "version": "3.1146.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1146.0.tgz", - "integrity": "sha512-3Mc8YkSu2KlgNNSuspaW+Fo0pTwX+fLYlC5ovH3yX3OoDg2RPtp3Py8KuQJr0FDlQj1tHw0K3+AtaH4NXlI3aQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/credential-provider-node": "^3.972.84", - "@aws-sdk/dynamodb-codec": "^3.973.46", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.31", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/fetch-http-handler": "^5.8.0", - "@smithy/node-http-handler": "^4.12.1", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/mi-updates-transformer/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz", - "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/mi-updates-transformer/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz", - "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/mi-updates-transformer/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz", - "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/mi-updates-transformer/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz", - "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "lambdas/supplier-allocator": { "name": "nhs-notify-supplier-api-allocate-letter", "version": "0.0.1", @@ -856,30 +643,6 @@ "node": ">=20.0.0" } }, - "lambdas/supplier-allocator/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/supplier-allocator/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/supplier-allocator/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -897,20 +660,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "lambdas/supplier-allocator/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/supplier-allocator/node_modules/@aws-sdk/middleware-sdk-sqs": { "version": "3.972.43", "license": "Apache-2.0", @@ -924,26 +673,6 @@ "node": ">=20.0.0" } }, - "lambdas/supplier-allocator/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, - "lambdas/supplier-allocator/node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "lambdas/supplier-allocator/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { "version": "1.1.1", "dependencies": { @@ -1014,30 +743,6 @@ "node": ">=20.0.0" } }, - "lambdas/supplier-config-ingress/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/supplier-config-ingress/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/supplier-config-ingress/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -1045,41 +750,14 @@ "@aws-sdk/core": "^3.978.1", "@aws-sdk/util-dynamodb": "^3.996.9", "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1145.0" - } - }, - "lambdas/supplier-config-ingress/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/supplier-config-ingress/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" + "@aws-sdk/client-dynamodb": "^3.1145.0" } }, "lambdas/supplier-config-ingress/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { @@ -1167,30 +845,6 @@ "node": ">=20.0.0" } }, - "lambdas/update-letter-queue/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/update-letter-queue/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/update-letter-queue/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -1208,33 +862,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "lambdas/update-letter-queue/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/update-letter-queue/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "lambdas/upsert-letter": { "name": "nhs-notify-supplier-api-upsert-letter", "version": "0.0.1", @@ -1320,30 +947,6 @@ "node": ">=20.0.0" } }, - "lambdas/upsert-letter/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/upsert-letter/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/upsert-letter/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -1361,40 +964,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "lambdas/upsert-letter/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "lambdas/upsert-letter/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, - "lambdas/upsert-letter/node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", "license": "MIT", @@ -1518,6 +1087,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-athena": { + "version": "3.1149.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-athena/-/client-athena-3.1149.0.tgz", + "integrity": "sha512-GPHrphuEDhybsEkcCOnmadtIkCAzJ6wckZ1gllkHhkicO5EccXwMHeZNsmTWyjkrnafD4EjSz5Oaf+HfbHLIZA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-node": "^3.972.85", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cloudwatch-logs": { "version": "3.1146.0", "dev": true, @@ -1536,6 +1124,27 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.1147.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1147.0.tgz", + "integrity": "sha512-lONixsTyj2jt5bb14ir4VYuBEy8kMNMZt7DSVxZ4HHE89hEfND5lO4Qj+F3Z74sENdFCaSAgK8RYQaiQWm5pfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/dynamodb-codec": "^3.973.46", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.31", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-kinesis": { "version": "3.1145.0", "license": "Apache-2.0", @@ -1625,14 +1234,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.978.1", + "version": "3.978.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.2.tgz", + "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.35.0", - "@smithy/signature-v4": "^5.7.3", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -1641,20 +1252,15 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.72", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.73.tgz", + "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1663,12 +1269,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.74", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.75.tgz", + "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", @@ -1679,19 +1287,21 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.17", + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.18.tgz", + "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/credential-provider-env": "^3.972.72", - "@aws-sdk/credential-provider-http": "^3.972.74", - "@aws-sdk/credential-provider-login": "^3.972.79", - "@aws-sdk/credential-provider-process": "^3.972.72", - "@aws-sdk/credential-provider-sso": "^3.973.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.78", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-login": "^3.972.80", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" @@ -1701,13 +1311,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.79", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.80.tgz", + "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1716,17 +1328,19 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.84", + "version": "3.972.85", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.85.tgz", + "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.72", - "@aws-sdk/credential-provider-http": "^3.972.74", - "@aws-sdk/credential-provider-ini": "^3.973.17", - "@aws-sdk/credential-provider-process": "^3.972.72", - "@aws-sdk/credential-provider-sso": "^3.973.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-ini": "^3.973.18", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" @@ -1736,12 +1350,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.72", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.73.tgz", + "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", + "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1750,14 +1366,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.16", + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.17.tgz", + "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", - "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/token-providers": "3.1149.0", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1766,11 +1384,76 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.78", + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.79.tgz", + "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.973.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.46.tgz", + "integrity": "sha512-ApnSubn+5C6HQGP7025q9w/YiNzKwimIMksP76xVij779BCCqcmJ9poB6hQ4syY9NUxqmEQLBqY0PktX53L8iQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz", + "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==", + "license": "Apache-2.0", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-dynamodb": { + "version": "3.1147.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1147.0.tgz", + "integrity": "sha512-232+7ND08nb0YZrCyCviYSpySiYDusCZo8mdovBo9eOHDqBvndiP7n5/CrtN5HjegGuwJYQ/0AedWu1ORVUGDA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/util-dynamodb": "^3.996.9", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1147.0" + } + }, + "node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.31.tgz", + "integrity": "sha512-JqOIXupzP6cpAmCCmuEaacbXo/bVCcg1NkglCJC6q4wDAvJO09qIs7POf50JNzmhOhWPtfXvzgWxb5nMQl8QiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.11", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", @@ -1809,13 +1492,15 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.46", + "version": "3.997.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.47.tgz", + "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", @@ -1841,11 +1526,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.47", + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.6", - "@smithy/signature-v4": "^5.7.3", + "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1854,13 +1541,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1138.0", + "version": "3.1149.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1149.0.tgz", + "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -1879,6 +1568,21 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-dynamodb": { + "version": "3.996.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.9.tgz", + "integrity": "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1111.0" + } + }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.41", "license": "Apache-2.0", @@ -1890,6 +1594,15 @@ "node": ">=20.0.0" } }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "license": "MIT", @@ -2279,7 +1992,6 @@ }, "node_modules/@balena/dockerignore": { "version": "1.0.2", - "dev": true, "license": "Apache-2.0" }, "node_modules/@bcoe/v8-coverage": { @@ -2649,8 +2361,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "dev": true, + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -2662,7 +2375,6 @@ }, "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { "version": "0.8.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -2679,7 +2391,6 @@ }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", - "dev": true, "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -2825,6 +2536,14 @@ "resolved": "internal/helpers", "link": true }, + "node_modules/@internal/letter-sla-breaches-common": { + "resolved": "lambdas/letter-sla-breaches/common", + "link": true + }, + "node_modules/@internal/sql-test-helpers": { + "resolved": "internal/sql-test-helpers", + "link": true + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "license": "ISC", @@ -3309,7 +3028,6 @@ }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -3355,7 +3073,6 @@ }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1" @@ -3809,27 +3526,22 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -3837,22 +3549,18 @@ }, "node_modules/@protobufjs/float": { "version": "1.0.2", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.2", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@redocly/ajv": { @@ -4006,7 +3714,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.35.1", + "version": "3.35.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.2.tgz", + "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.19.0", @@ -4643,7 +4353,6 @@ }, "node_modules/@types/docker-modem": { "version": "3.0.6", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4652,7 +4361,6 @@ }, "node_modules/@types/dockerode": { "version": "4.0.1", - "dev": true, "license": "MIT", "dependencies": { "@types/docker-modem": "*", @@ -4760,7 +4468,6 @@ }, "node_modules/@types/ssh2": { "version": "1.15.5", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "^18.11.18" @@ -4768,7 +4475,6 @@ }, "node_modules/@types/ssh2-streams": { "version": "0.1.13", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4776,7 +4482,6 @@ }, "node_modules/@types/ssh2/node_modules/@types/node": { "version": "18.19.130", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4784,7 +4489,6 @@ }, "node_modules/@types/ssh2/node_modules/undici-types": { "version": "5.26.5", - "dev": true, "license": "MIT" }, "node_modules/@types/stack-utils": { @@ -5088,7 +4792,6 @@ }, "node_modules/abort-controller": { "version": "3.0.0", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -5313,7 +5016,6 @@ }, "node_modules/archiver": { "version": "7.0.1", - "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^5.0.2", @@ -5330,7 +5032,6 @@ }, "node_modules/archiver-utils": { "version": "5.0.2", - "dev": true, "license": "MIT", "dependencies": { "glob": "^10.0.0", @@ -5347,12 +5048,10 @@ }, "node_modules/archiver-utils/node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/archiver-utils/node_modules/brace-expansion": { "version": "2.1.7", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5360,7 +5059,6 @@ }, "node_modules/archiver-utils/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -5383,7 +5081,6 @@ }, "node_modules/archiver-utils/node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -5391,7 +5088,6 @@ }, "node_modules/archiver-utils/node_modules/glob": { "version": "10.5.0", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -5410,7 +5106,6 @@ }, "node_modules/archiver-utils/node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -5429,12 +5124,10 @@ }, "node_modules/archiver-utils/node_modules/lru-cache": { "version": "10.4.3", - "dev": true, "license": "ISC" }, "node_modules/archiver-utils/node_modules/minimatch": { "version": "9.0.9", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -5448,7 +5141,6 @@ }, "node_modules/archiver-utils/node_modules/path-scurry": { "version": "1.11.1", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -5463,7 +5155,6 @@ }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "4.7.0", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -5478,7 +5169,6 @@ }, "node_modules/archiver/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -5501,7 +5191,6 @@ }, "node_modules/archiver/node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -5509,7 +5198,6 @@ }, "node_modules/archiver/node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -5528,7 +5216,6 @@ }, "node_modules/archiver/node_modules/readable-stream": { "version": "4.7.0", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -5716,7 +5403,6 @@ }, "node_modules/asn1": { "version": "0.2.6", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": "~2.1.0" @@ -5748,7 +5434,6 @@ }, "node_modules/async": { "version": "3.2.6", - "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -5761,7 +5446,6 @@ }, "node_modules/async-lock": { "version": "1.4.1", - "dev": true, "license": "MIT" }, "node_modules/asynckit": { @@ -5902,7 +5586,6 @@ }, "node_modules/b4a": { "version": "1.8.1", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -6010,7 +5693,6 @@ }, "node_modules/bare-events": { "version": "2.9.1", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -6023,7 +5705,6 @@ }, "node_modules/bare-fs": { "version": "4.7.2", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -6046,7 +5727,6 @@ }, "node_modules/bare-os": { "version": "3.9.1", - "dev": true, "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -6054,7 +5734,6 @@ }, "node_modules/bare-path": { "version": "3.0.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-os": "^3.0.1" @@ -6062,7 +5741,6 @@ }, "node_modules/bare-stream": { "version": "2.13.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -6087,7 +5765,6 @@ }, "node_modules/bare-url": { "version": "2.4.5", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -6131,7 +5808,6 @@ }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" @@ -6139,7 +5815,6 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -6149,7 +5824,6 @@ }, "node_modules/bl/node_modules/buffer": { "version": "5.7.1", - "dev": true, "funding": [ { "type": "github", @@ -6416,7 +6090,6 @@ }, "node_modules/buffer-crc32": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8.0.0" @@ -6424,7 +6097,6 @@ }, "node_modules/buildcheck": { "version": "0.0.7", - "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -6448,7 +6120,6 @@ }, "node_modules/byline": { "version": "5.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6630,7 +6301,6 @@ }, "node_modules/chownr": { "version": "1.1.4", - "dev": true, "license": "ISC" }, "node_modules/ci-info": { @@ -6822,7 +6492,6 @@ }, "node_modules/compress-commons": { "version": "6.0.2", - "dev": true, "license": "MIT", "dependencies": { "crc-32": "^1.2.0", @@ -6837,7 +6506,6 @@ }, "node_modules/compress-commons/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -6860,7 +6528,6 @@ }, "node_modules/compress-commons/node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -6868,7 +6535,6 @@ }, "node_modules/compress-commons/node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -6887,7 +6553,6 @@ }, "node_modules/compress-commons/node_modules/readable-stream": { "version": "4.7.0", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -7175,12 +6840,10 @@ }, "node_modules/core-util-is": { "version": "1.0.3", - "dev": true, "license": "MIT" }, "node_modules/cpu-features": { "version": "0.0.10", - "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -7193,7 +6856,6 @@ }, "node_modules/crc-32": { "version": "1.2.2", - "dev": true, "license": "Apache-2.0", "bin": { "crc32": "bin/crc32.njs" @@ -7204,7 +6866,6 @@ }, "node_modules/crc32-stream": { "version": "6.0.0", - "dev": true, "license": "MIT", "dependencies": { "crc-32": "^1.2.0", @@ -7216,7 +6877,6 @@ }, "node_modules/crc32-stream/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -7239,7 +6899,6 @@ }, "node_modules/crc32-stream/node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -7247,7 +6906,6 @@ }, "node_modules/crc32-stream/node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -7266,7 +6924,6 @@ }, "node_modules/crc32-stream/node_modules/readable-stream": { "version": "4.7.0", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -7593,7 +7250,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.5.0.tgz", "integrity": "sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==", - "dev": true, "license": "MIT", "dependencies": { "yaml": "^2.2.2" @@ -7604,7 +7260,6 @@ }, "node_modules/docker-modem": { "version": "5.0.7", - "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.1", @@ -7618,7 +7273,6 @@ }, "node_modules/dockerode": { "version": "5.0.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -7634,7 +7288,6 @@ }, "node_modules/dockerode/node_modules/tar-fs": { "version": "2.1.5", - "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -7645,7 +7298,6 @@ }, "node_modules/dockerode/node_modules/tar-stream": { "version": "2.2.0", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -9262,7 +8914,6 @@ }, "node_modules/event-target-shim": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9281,7 +8932,6 @@ }, "node_modules/events-universal": { "version": "1.0.1", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -9401,7 +9051,6 @@ }, "node_modules/fast-fifo": { "version": "1.3.2", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9685,7 +9334,6 @@ }, "node_modules/fs-constants": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -9807,7 +9455,6 @@ }, "node_modules/get-port": { "version": "5.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10776,7 +10423,6 @@ }, "node_modules/is-stream": { "version": "2.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11860,7 +11506,6 @@ }, "node_modules/lazystream": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "readable-stream": "^2.0.5" @@ -11871,7 +11516,6 @@ }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -11885,12 +11529,10 @@ }, "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -12126,7 +11768,6 @@ }, "node_modules/lodash.camelcase": { "version": "4.3.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -12156,7 +11797,6 @@ }, "node_modules/long": { "version": "5.3.2", - "dev": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -12325,7 +11965,6 @@ }, "node_modules/minimatch": { "version": "5.1.9", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -12336,12 +11975,10 @@ }, "node_modules/minimatch/node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/minimatch/node_modules/brace-expansion": { "version": "2.1.7", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -12374,11 +12011,12 @@ }, "node_modules/mkdirp-classic": { "version": "0.5.3", - "dev": true, "license": "MIT" }, "node_modules/mnemonist": { "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "license": "MIT", "dependencies": { "obliterator": "^1.6.1" @@ -12466,7 +12104,6 @@ }, "node_modules/nan": { "version": "2.27.0", - "dev": true, "license": "MIT", "optional": true }, @@ -12521,6 +12158,10 @@ "resolved": "lambdas/supplier-allocator", "link": true }, + "node_modules/nhs-notify-supplier-api-eventpub-parquet-transformer": { + "resolved": "lambdas/eventpub-parquet-transformer", + "link": true + }, "node_modules/nhs-notify-supplier-api-handler": { "resolved": "lambdas/api-handler", "link": true @@ -12537,6 +12178,18 @@ "resolved": "lambdas/mi-updates-transformer", "link": true }, + "node_modules/nhs-notify-supplier-api-populate-sameday-breaches": { + "resolved": "lambdas/letter-sla-breaches/populate-sameday-breaches", + "link": true + }, + "node_modules/nhs-notify-supplier-api-populate-standard-breaches": { + "resolved": "lambdas/letter-sla-breaches/populate-standard-breaches", + "link": true + }, + "node_modules/nhs-notify-supplier-api-populate-supplier-config-cache": { + "resolved": "lambdas/letter-sla-breaches/populate-supplier-config-cache", + "link": true + }, "node_modules/nhs-notify-supplier-api-supplier-config-ingress": { "resolved": "lambdas/supplier-config-ingress", "link": true @@ -12818,6 +12471,8 @@ }, "node_modules/obliterator": { "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "license": "MIT" }, "node_modules/obug": { @@ -13498,7 +13153,6 @@ }, "node_modules/process": { "version": "0.11.10", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -13506,7 +13160,6 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "dev": true, "license": "MIT" }, "node_modules/process-warning": { @@ -13535,7 +13188,6 @@ }, "node_modules/proper-lockfile": { "version": "4.1.2", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -13545,12 +13197,10 @@ }, "node_modules/proper-lockfile/node_modules/signal-exit": { "version": "3.0.7", - "dev": true, "license": "ISC" }, "node_modules/properties-reader": { "version": "3.0.1", - "dev": true, "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", @@ -13566,7 +13216,6 @@ }, "node_modules/properties-reader/node_modules/mkdirp": { "version": "3.0.1", - "dev": true, "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" @@ -13580,7 +13229,6 @@ }, "node_modules/protobufjs": { "version": "7.6.5", - "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13819,7 +13467,6 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -13832,7 +13479,6 @@ }, "node_modules/readdir-glob": { "version": "1.1.3", - "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.1.0" @@ -14003,7 +13649,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14082,7 +13727,6 @@ }, "node_modules/retry": { "version": "0.12.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -14710,7 +14354,6 @@ }, "node_modules/split-ca": { "version": "1.0.1", - "dev": true, "license": "ISC" }, "node_modules/split2": { @@ -14726,7 +14369,6 @@ }, "node_modules/ssh-remote-port-forward": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "@types/ssh2": "^0.5.48", @@ -14735,7 +14377,6 @@ }, "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { "version": "0.5.52", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -14744,7 +14385,6 @@ }, "node_modules/ssh2": { "version": "1.17.0", - "dev": true, "hasInstallScript": true, "dependencies": { "asn1": "^0.2.6", @@ -14818,7 +14458,6 @@ }, "node_modules/streamx": { "version": "2.27.0", - "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -14828,7 +14467,6 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -15108,7 +14746,6 @@ }, "node_modules/tar-fs": { "version": "3.1.3", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -15121,7 +14758,6 @@ }, "node_modules/tar-stream": { "version": "3.2.0", - "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -15132,7 +14768,6 @@ }, "node_modules/teex": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "streamx": "^2.12.5" @@ -15244,7 +14879,6 @@ "version": "12.2.0", "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.2.0.tgz", "integrity": "sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA==", - "dev": true, "license": "MIT", "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -15269,7 +14903,6 @@ }, "node_modules/text-decoder": { "version": "1.2.7", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -15347,7 +14980,6 @@ }, "node_modules/tmp": { "version": "0.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -15650,7 +15282,6 @@ }, "node_modules/tweetnacl": { "version": "0.14.5", - "dev": true, "license": "Unlicense" }, "node_modules/type-check": { @@ -16046,7 +15677,6 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/utility-types": { @@ -16524,7 +16154,6 @@ }, "node_modules/yaml": { "version": "2.9.1", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -16543,7 +16172,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -16560,7 +16188,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -16568,7 +16195,6 @@ }, "node_modules/yargs/node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -16581,7 +16207,6 @@ }, "node_modules/yargs/node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -16616,7 +16241,6 @@ }, "node_modules/zip-stream": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^5.0.0", @@ -16629,7 +16253,6 @@ }, "node_modules/zip-stream/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -16652,7 +16275,6 @@ }, "node_modules/zip-stream/node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -16660,7 +16282,6 @@ }, "node_modules/zip-stream/node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -16679,7 +16300,6 @@ }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "4.7.0", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -16760,30 +16380,6 @@ "node": ">=20.0.0" } }, - "scripts/utilities/letter-test-data/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "scripts/utilities/letter-test-data/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "scripts/utilities/letter-test-data/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -16801,33 +16397,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "scripts/utilities/letter-test-data/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "scripts/utilities/letter-test-data/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "scripts/utilities/letter-test-data/node_modules/ansi-regex": { "version": "6.4.0", "license": "MIT", @@ -16989,30 +16558,6 @@ "node": ">=20.0.0" } }, - "scripts/utilities/supplier-data/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "scripts/utilities/supplier-data/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "scripts/utilities/supplier-data/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -17030,33 +16575,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "scripts/utilities/supplier-data/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "scripts/utilities/supplier-data/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "scripts/utilities/supplier-data/node_modules/ansi-regex": { "version": "6.4.0", "license": "MIT", @@ -17274,30 +16792,6 @@ "node": ">=20.0.0" } }, - "tests/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.973.46", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "tests/node_modules/@aws-sdk/endpoint-cache": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "mnemonist": "0.38.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "tests/node_modules/@aws-sdk/lib-dynamodb": { "version": "3.1145.0", "license": "Apache-2.0", @@ -17315,20 +16809,6 @@ "@aws-sdk/client-dynamodb": "^3.1145.0" } }, - "tests/node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.31", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/endpoint-cache": "^3.972.11", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", - "@smithy/types": "^4.19.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "tests/node_modules/@aws-sdk/middleware-sdk-sqs": { "version": "3.972.43", "license": "Apache-2.0", @@ -17342,19 +16822,6 @@ "node": ">=20.0.0" } }, - "tests/node_modules/@aws-sdk/util-dynamodb": { - "version": "3.996.9", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "^3.1111.0" - } - }, "tests/node_modules/@babel/code-frame": { "version": "8.0.6", "dev": true, diff --git a/package.json b/package.json index 4e7f0e70d..e63f7080e 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "axios": "^1.20.0", "fast-uri": "^3.1.7", "fast-xml-parser": "^5.5.6", + "@grpc/grpc-js": "^1.14.5", "@isaacs/brace-expansion": "^5.0.1", "minimatch@^3.0.0": { "brace-expansion": "^1.1.20" @@ -149,6 +150,7 @@ "docs", "internal/*", "lambdas/*", + "lambdas/letter-sla-breaches/*", "pact-contracts", "scripts/utilities/*", "tests", diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index cda56bc1d..6ad5ec975 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -9,7 +9,7 @@ sonar.exclusions=lambdas/**/src/__tests__/**/* sonar.test.inclusions=**/*.test.*, **/*.spec.* sonar.terraform.provider.aws.version=5.54.1 sonar.cpd.exclusions=**.test.* -sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, +sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, internal/sql-test-helpers/**, #sonar.python.coverage.reportPaths=.coverage/coverage.xml sonar.javascript.lcov.reportPaths=lcov.info