From 4906c7d9693e2211f8bcf10872654c51fa023082 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 4 Feb 2026 10:44:17 +0000 Subject: [PATCH 01/19] CCM-13303: Update Generate Report schema to require a reportDate --- ...ters-reporting-generate-report-data.schema.yaml | 9 +++------ .../2025-10-draft/defs/mesh.schema.yaml | 14 ++++---------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml index cbc88c60..e4dace84 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml @@ -6,11 +6,8 @@ additionalProperties: false properties: senderId: $ref: ../defs/requests.schema.yaml#/properties/senderId - reportPeriodStartTime: - $ref: ../defs/mesh.schema.yaml#/properties/reportPeriodStartTime - reportPeriodEndTime: - $ref: ../defs/mesh.schema.yaml#/properties/reportPeriodEndTime + reportDate: + $ref: ../defs/mesh.schema.yaml#/properties/reportDate required: - senderId - - reportPeriodStartTime - - reportPeriodEndTime + - reportDate diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/mesh.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/mesh.schema.yaml index 12a0d298..e7398162 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/mesh.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/mesh.schema.yaml @@ -21,15 +21,9 @@ properties: description: Id of the mailbox used to receive daily reports examples: - "MAILBOX02" - reportPeriodStartTime: + reportDate: type: string - format: date-time - description: Start time of the reporting period + format: date + description: Date covered by the report examples: - - "2025-12-03T00:00:00Z" - reportPeriodEndTime: - type: string - format: date-time - description: End time of the reporting period - examples: - - "2025-12-04T23:59:59Z" + - "2025-12-03" From e6d2b581ff2abf49e24ce92361320ffab02d249a Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 10:21:02 +0000 Subject: [PATCH 02/19] CCM-13304: Initial commit --- .../terraform/components/dl/README.md | 4 + .../cloudwatch_event_rule_generate_report.tf | 18 + ...a_event_source_mapping_report_generator.tf | 10 + .../terraform/components/dl/locals.tf | 1 + .../dl/module_lambda_report_generator.tf | 121 ++ .../dl/module_sqs_report_generator.tf | 44 + .../terraform/components/dl/variables.tf | 12 + lambdas/report-generator/jest.config.ts | 15 + lambdas/report-generator/package.json | 24 + .../src/__tests__/index.test.ts | 11 + .../src/apis/sqs-trigger-lambda.ts | 211 ++++ .../src/app/report-generator.ts | 47 + lambdas/report-generator/src/container.ts | 67 ++ lambdas/report-generator/src/index.ts | 6 + lambdas/report-generator/src/infra/config.ts | 29 + .../report-generator/src/queries/report.sql | 18 + lambdas/report-generator/tsconfig.json | 11 + package-lock.json | 1031 +++++++++++++---- package.json | 1 + utils/utils/jest.config.ts | 2 +- utils/utils/package.json | 17 +- utils/utils/src/index.ts | 1 + utils/utils/src/reporting/data-repository.ts | 71 ++ utils/utils/src/reporting/index.ts | 3 + utils/utils/src/reporting/report-service.ts | 96 ++ .../utils/src/reporting/storage-repository.ts | 39 + 26 files changed, 1674 insertions(+), 236 deletions(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_generate_report.tf create mode 100644 infrastructure/terraform/components/dl/lambda_event_source_mapping_report_generator.tf create mode 100644 infrastructure/terraform/components/dl/module_lambda_report_generator.tf create mode 100644 infrastructure/terraform/components/dl/module_sqs_report_generator.tf create mode 100644 lambdas/report-generator/jest.config.ts create mode 100644 lambdas/report-generator/package.json create mode 100644 lambdas/report-generator/src/__tests__/index.test.ts create mode 100644 lambdas/report-generator/src/apis/sqs-trigger-lambda.ts create mode 100644 lambdas/report-generator/src/app/report-generator.ts create mode 100644 lambdas/report-generator/src/container.ts create mode 100644 lambdas/report-generator/src/index.ts create mode 100644 lambdas/report-generator/src/infra/config.ts create mode 100644 lambdas/report-generator/src/queries/report.sql create mode 100644 lambdas/report-generator/tsconfig.json create mode 100644 utils/utils/src/reporting/data-repository.ts create mode 100644 utils/utils/src/reporting/index.ts create mode 100644 utils/utils/src/reporting/report-service.ts create mode 100644 utils/utils/src/reporting/storage-repository.ts diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 010e0156..69637135 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -13,6 +13,8 @@ No requirements. | [apim\_auth\_token\_url](#input\_apim\_auth\_token\_url) | URL to generate an APIM auth token | `string` | `"https://int.api.service.nhs.uk/oauth2/token"` | no | | [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to PDM | `string` | `"https://int.api.service.nhs.uk"` | no | | [apim\_keygen\_schedule](#input\_apim\_keygen\_schedule) | Schedule to refresh key pairs if necessary | `string` | `"cron(0 14 * * ? *)"` | no | +| [athena\_query\_max\_polling\_attemps](#input\_athena\_query\_max\_polling\_attemps) | The number of times athena will be polled to check if a query is completed | `number` | `50` | no | +| [athena\_query\_polling\_time\_seconds](#input\_athena\_query\_polling\_time\_seconds) | The amount of time in seconds to wait between each athena poll | `number` | `15` | no | | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no | | [core\_notify\_url](#input\_core\_notify\_url) | The URL used to send requests to Notify | `string` | `"https://sandbox.api.service.nhs.uk"` | no | @@ -58,6 +60,7 @@ No requirements. | [print\_analyser](#module\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [report\_event\_transformer](#module\_report\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [report\_generator](#module\_report\_generator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | | [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | | [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | @@ -75,6 +78,7 @@ No requirements. | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_print\_analyser](#module\_sqs\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_report\_generator](#module\_sqs\_report\_generator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_scanner](#module\_sqs\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_generate_report.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_generate_report.tf new file mode 100644 index 00000000..3d14b8d2 --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_generate_report.tf @@ -0,0 +1,18 @@ +resource "aws_cloudwatch_event_rule" "generate_report" { + name = "${local.csi}-generate-report" + description = "Generate Report event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.reporting.generate.report.v1" + ], + } + }) +} + +resource "aws_cloudwatch_event_target" "generate_report_report_generator" { + rule = aws_cloudwatch_event_rule.generate_report.name + arn = module.sqs_report_generator.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_report_generator.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_report_generator.tf new file mode 100644 index 00000000..432181a6 --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_report_generator.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "report_generator" { + event_source_arn = module.sqs_report_generator.sqs_queue_arn + function_name = module.report_generator.function_name + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index 5838de64..301f11f0 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -3,6 +3,7 @@ locals { apim_api_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/api_key" apim_keystore_s3_bucket = "nhs-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-static-assets" apim_private_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/private_key" + athena_reporting_database = "${local.csi}-reporting" aws_lambda_functions_dir_path = "../../../../lambdas" deploy_pdm_mock = var.enable_pdm_mock firehose_output_path_prefix = "kinesis-firehose-output" diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf new file mode 100644 index 00000000..9741fa79 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -0,0 +1,121 @@ +module "report_generator" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-lambda.zip" + + function_name = "report-generator" + description = "A function to generator reports from an event" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.report_generator_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 = "report-generator/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 60 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + "ATHENA_WORKGROUP" = aws_athena_workgroup.reporting.name + "ATHENA_DATABASE" = local.athena_reporting_database + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "MAX_POLL_LIMIT" = var.athena_query_max_polling_attemps + "REPORTING_BUCKET" = s3bucket_reporting.name + "REPORT_NAME" = "completed_communications" + "WAIT_FOR_IN_SECONDS" = var.athena_query_polling_time_seconds + } +} + +data "aws_iam_policy_document" "report_generator_lambda" { + statement { + sid = "AllowS3Get" + effect = "Allow" + + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + + resources = [ + "${module.s3bucket_reporting.arn}/*" + ] + } + + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + + statement { + sid = "SQSPermissionsReportGeneratorQueue" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_report_generator.sqs_queue_arn, + ] + } + + statement { + sid = "PutEvents" + effect = "Allow" + + actions = [ + "events:PutEvents", + ] + + resources = [ + aws_cloudwatch_event_bus.main.arn, + ] + } + + statement { + sid = "SQSPermissionsEventPublisherDLQ" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + ] + + resources = [ + module.sqs_event_publisher_errors.sqs_queue_arn, + ] + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_report_generator.tf b/infrastructure/terraform/components/dl/module_sqs_report_generator.tf new file mode 100644 index 00000000..a494ee60 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_report_generator.tf @@ -0,0 +1,44 @@ +module "sqs_report_generator" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + name = "report-generator" + + sqs_kms_key_arn = module.kms.key_arn + + visibility_timeout_seconds = 60 + + create_dlq = true + + sqs_policy_overload = data.aws_iam_policy_document.sqs_report_generator.json +} + +data "aws_iam_policy_document" "sqs_report_generator" { + statement { + sid = "AllowEventBridgeToSendMessage" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + + actions = [ + "sqs:SendMessage" + ] + + resources = [ + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-report-generator-queue" + ] + + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_cloudwatch_event_rule.generate_report.arn] + } + } +} diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index 3a81e659..77b888c0 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -192,3 +192,15 @@ variable "default_cloudwatch_event_bus_name" { description = "The name of the default cloudwatch event bus. This is needed as GuardDuty Scan Result events are sent to the default bus" default = "default" } + +variable "athena_query_max_polling_attemps" { + type = number + description = "The number of times athena will be polled to check if a query is completed" + default = 50 +} + +variable "athena_query_polling_time_seconds" { + type = number + description = "The amount of time in seconds to wait between each athena poll" + default = 15 +} diff --git a/lambdas/report-generator/jest.config.ts b/lambdas/report-generator/jest.config.ts new file mode 100644 index 00000000..c23f9ba3 --- /dev/null +++ b/lambdas/report-generator/jest.config.ts @@ -0,0 +1,15 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = { + ...baseJestConfig, + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + } +}; + +export default config; diff --git a/lambdas/report-generator/package.json b/lambdas/report-generator/package.json new file mode 100644 index 00000000..68bbff40 --- /dev/null +++ b/lambdas/report-generator/package.json @@ -0,0 +1,24 @@ +{ + "dependencies": { + "@aws-sdk/client-athena": "^3.984.0", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.9.3" + }, + "name": "nhs-notify-digital-letters-report-generator", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/report-generator/src/__tests__/index.test.ts b/lambdas/report-generator/src/__tests__/index.test.ts new file mode 100644 index 00000000..6241b09f --- /dev/null +++ b/lambdas/report-generator/src/__tests__/index.test.ts @@ -0,0 +1,11 @@ +import { handler } from 'index'; + +jest.mock('container', () => ({ + createContainer: jest.fn(() => ({})), +})); + +describe('index', () => { + it('should export handler', () => { + expect(handler).toBeDefined(); + }); +}); diff --git a/lambdas/report-generator/src/apis/sqs-trigger-lambda.ts b/lambdas/report-generator/src/apis/sqs-trigger-lambda.ts new file mode 100644 index 00000000..b81f9fcf --- /dev/null +++ b/lambdas/report-generator/src/apis/sqs-trigger-lambda.ts @@ -0,0 +1,211 @@ +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from 'aws-lambda'; +import { randomUUID } from 'node:crypto'; +import type { + ReportGenerator, + ReportGeneratorOutcome, + ReportGeneratorResult, +} from 'app/report-generator'; +import generateReportValidator from 'digital-letters-events/GenerateReport.js'; +import reportGeneratedValidator from 'digital-letters-events/ReportGenerated.js'; +import { GenerateReport, ReportGenerated } from 'digital-letters-events'; +import { EventPublisher, Logger } from 'utils'; + +interface ProcessingResult { + result: ReportGeneratorResult; + item?: GenerateReport; +} + +interface CreateHandlerDependencies { + reportGenerator: ReportGenerator; + eventPublisher: EventPublisher; + logger: Logger; +} + +interface ValidatedRecord { + messageId: string; + event: GenerateReport; +} + +function validateRecord( + { body, messageId }: { body: string; messageId: string }, + logger: Logger, +): ValidatedRecord | null { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + + const isEventValid = generateReportValidator(sqsEventDetail); + if (!isEventValid) { + logger.error({ + err: generateReportValidator.errors, + description: 'Error parsing queue entry', + }); + return null; + } + + return { messageId, event: sqsEventDetail }; + } catch (error) { + logger.error({ + err: error, + description: 'Error parsing SQS record', + }); + return null; + } +} + +async function processRecord( + { event, messageId }: ValidatedRecord, + reportGenerator: ReportGenerator, + logger: Logger, + batchItemFailures: SQSBatchItemFailure[], +): Promise { + try { + const result = await reportGenerator.generate(event); + + if (result.outcome === 'failed') { + batchItemFailures.push({ itemIdentifier: messageId }); + return { result: { outcome: 'failed' }, item: event }; + } + + return { result, item: event }; + } catch (error) { + logger.error({ + err: error, + description: 'Error during SQS trigger handler', + }); + batchItemFailures.push({ itemIdentifier: messageId }); + return { result: { outcome: 'failed' } }; + } +} + +interface CategorizedResults { + processed: Record; + successfulItems: { event: GenerateReport; reportUri: string }[]; + failedItems: GenerateReport[]; +} + +function categorizeResults( + results: PromiseSettledResult[], + logger: Logger, +): CategorizedResults { + const processed: Record = { + retrieved: results.length, + generated: 0, + failed: 0, + }; + + const successfulItems: { + event: GenerateReport; + reportUri: string; + }[] = []; + const failedItems: GenerateReport[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + const { item, result: itemResult } = result.value; + processed[itemResult.outcome] += 1; + + if (item) { + if (itemResult.outcome === 'generated' && itemResult.reportUri) { + successfulItems.push({ + event: item, + reportUri: itemResult.reportUri, + }); + } else { + failedItems.push(item); + } + } + } else { + logger.error({ err: result.reason }); + processed.failed += 1; + } + } + + return { processed, successfulItems, failedItems }; +} + +async function publishSuccessfulEvents( + successfulItems: { event: GenerateReport; reportUri: string }[], + eventPublisher: EventPublisher, + logger: Logger, +): Promise { + if (successfulItems.length === 0) return; + + try { + const reportGeneratedEvents: ReportGenerated[] = successfulItems.map( + ({ event, reportUri }) => ({ + ...event, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.reporting.report.generated.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-reporting-report-generated-data.schema.json', + data: { + senderId: event.data.senderId, + reportUri, + }, + }), + ); + + const submittedFailedEvents = + await eventPublisher.sendEvents( + reportGeneratedEvents, + reportGeneratedValidator, + ); + if (submittedFailedEvents.length > 0) { + logger.warn({ + description: 'Some successful events failed to publish', + failedCount: submittedFailedEvents.length, + totalAttempted: successfulItems.length, + }); + } + } catch (error) { + logger.warn({ + err: error, + description: 'Failed to send successful events to EventBridge', + eventCount: successfulItems.length, + }); + } +} + +export const createHandler = ({ + eventPublisher, + logger, + reportGenerator, +}: CreateHandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const batchItemFailures: SQSBatchItemFailure[] = []; + + const validatedRecords: ValidatedRecord[] = []; + for (const record of sqsEvent.Records) { + const validated = validateRecord(record, logger); + if (validated) { + validatedRecords.push(validated); + } else { + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + const promises = validatedRecords.map((record) => + processRecord(record, reportGenerator, logger, batchItemFailures), + ); + + const results = await Promise.allSettled(promises); + const { processed, successfulItems } = categorizeResults(results, logger); + + await publishSuccessfulEvents(successfulItems, eventPublisher, logger); + + logger.info({ + description: 'Processed SQS Event.', + ...processed, + }); + + return { batchItemFailures }; + }; + +export default createHandler; diff --git a/lambdas/report-generator/src/app/report-generator.ts b/lambdas/report-generator/src/app/report-generator.ts new file mode 100644 index 00000000..a211ad26 --- /dev/null +++ b/lambdas/report-generator/src/app/report-generator.ts @@ -0,0 +1,47 @@ +import { IReportService, Logger } from 'utils'; +import { GenerateReport } from 'digital-letters-events'; +import fs from 'node:fs'; + +export type ReportGeneratorOutcome = 'generated' | 'failed'; + +export type ReportGeneratorResult = { + outcome: ReportGeneratorOutcome; + reportUri?: string; +}; + +export class ReportGenerator { + constructor( + private readonly logger: Logger, + private readonly reportService: IReportService, + private readonly reportName: string, + ) {} + + async generate(event: GenerateReport): Promise { + try { + const query = fs.readFileSync('/var/task/queries/report.sql', 'utf8'); + const { senderId } = event.data; + const { reportDate } = event.data; + const reportFilePath = `reports/${senderId}/${this.reportName}/${this.reportName}_${senderId}_${reportDate}.csv`; + + this.logger.info( + `Generating report for sender ${senderId} and date ${reportDate}`, + ); + + const location = await this.reportService.generateReport( + query, + [reportDate, senderId], + reportFilePath, + ); + + return { outcome: 'generated', reportUri: location }; + } catch (error) { + this.logger.error({ + err: error, + description: 'Error generating report', + senderId: event.data.senderId, + reportDate: event.data.reportDate, + }); + return { outcome: 'failed' }; + } + } +} diff --git a/lambdas/report-generator/src/container.ts b/lambdas/report-generator/src/container.ts new file mode 100644 index 00000000..8fecf92a --- /dev/null +++ b/lambdas/report-generator/src/container.ts @@ -0,0 +1,67 @@ +import { + AthenaRepository, + EventPublisher, + ReportService, + createStorageRepository, + eventBridgeClient, + logger, + s3Client, + sqsClient, +} from 'utils'; +import { loadConfig } from 'infra/config'; +import { ReportGenerator } from 'app/report-generator'; +import { AthenaClient } from '@aws-sdk/client-athena'; + +export const createContainer = () => { + const { + athenaDatabase, + athenaWorkgroup, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + maxPollLimit, + reportName, + reportingBucket, + waitForInSeconds, + } = loadConfig(); + + const athenaClient = new AthenaClient({ + region: 'eu-west-2', + }); + + const dataRepository = new AthenaRepository(athenaClient, { + athenaWorkgroup, + athenaDatabase, + }); + + const storageRepository = createStorageRepository({ + s3Client, + reportingBucketName: reportingBucket, + logger, + }); + + const reportService = new ReportService( + dataRepository, + storageRepository, + maxPollLimit, + waitForInSeconds, + logger, + ); + + const reportGenerator = new ReportGenerator(logger, reportService, reportName); + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + return { + reportGenerator, + eventPublisher, + logger, + }; +}; + +export default createContainer; diff --git a/lambdas/report-generator/src/index.ts b/lambdas/report-generator/src/index.ts new file mode 100644 index 00000000..65f60969 --- /dev/null +++ b/lambdas/report-generator/src/index.ts @@ -0,0 +1,6 @@ +import { createHandler } from 'apis/sqs-trigger-lambda'; +import { createContainer } from 'container'; + +export const handler = createHandler(createContainer()); + +export default handler; diff --git a/lambdas/report-generator/src/infra/config.ts b/lambdas/report-generator/src/infra/config.ts new file mode 100644 index 00000000..b93c0d4e --- /dev/null +++ b/lambdas/report-generator/src/infra/config.ts @@ -0,0 +1,29 @@ +import { defaultConfigReader } from 'utils'; + +export type ReportGeneratorConfig = { + athenaWorkgroup: string; + athenaDatabase: string; + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; + maxPollLimit: number; + reportingBucket: string; + reportName: string; + waitForInSeconds: number; +}; + +export function loadConfig(): ReportGeneratorConfig { + return { + athenaWorkgroup: defaultConfigReader.getValue('ATHENA_WORKGROUP'), + athenaDatabase: defaultConfigReader.getValue('ATHENA_DATABASE'), + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + maxPollLimit: defaultConfigReader.getInt('MAX_POLL_LIMIT'), + reportingBucket: defaultConfigReader.getValue('REPORTING_BUCKET'), + reportName: defaultConfigReader.getValue('REPORT_NAME'), + waitForInSeconds: defaultConfigReader.getInt('WAIT_FOR_IN_SECONDS'), + }; +} diff --git a/lambdas/report-generator/src/queries/report.sql b/lambdas/report-generator/src/queries/report.sql new file mode 100644 index 00000000..9228b625 --- /dev/null +++ b/lambdas/report-generator/src/queries/report.sql @@ -0,0 +1,18 @@ +WITH vars AS ( + SELECT + CAST(? AS DATE) AS dt, + ? AS senderid +) +SELECT + e.messagereference, + e.pagecount, + e.supplierid, + e.time, + e.type, + e.senderid +FROM event_record e +CROSS JOIN vars v +WHERE e.senderid = v.senderid + AND e.__year = year(v.dt) + AND e.__month = month(v.dt) + AND e.__day = day(v.dt); diff --git a/lambdas/report-generator/tsconfig.json b/lambdas/report-generator/tsconfig.json new file mode 100644 index 00000000..f7bcaa1f --- /dev/null +++ b/lambdas/report-generator/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 4bbd71c9..e62c92a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "lambdas/print-analyser", "lambdas/report-event-transformer", "lambdas/move-scanned-files-lambda", + "lambdas/report-generator", "utils/utils", "utils/sender-management", "src/cloudevents", @@ -3372,6 +3373,313 @@ "dev": true, "license": "MIT" }, + "lambdas/report-generator": { + "name": "nhs-notify-digital-letters-report-generator", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-athena": "^3.984.0", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.9.3" + } + }, + "lambdas/report-generator/node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/report-generator/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "lambdas/report-generator/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "lambdas/report-generator/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/report-generator/node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/report-generator/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "lambdas/report-generator/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/report-generator/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "lambdas/ttl-create-lambda": { "name": "nhs-notify-digital-letters-ttl-create-lambda", "version": "0.0.1", @@ -4546,30 +4854,27 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.981.0.tgz", - "integrity": "sha512-pIkGmwKixO9WZlYob6NjFMOcx7syiBX7zuDOJvoWSOwBZKnrBZgoICm4cWW8x52ROw4d14O5vkjnHvRu+WuyoA==", + "node_modules/@aws-sdk/client-athena": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-athena/-/client-athena-3.984.0.tgz", + "integrity": "sha512-fK51EsoB4G8EI3ge2g9jaMJnu+tTSXqNkwRm9b4Bd27HQrKfZx0apwIbswNlUudSEDiU/lJwG78sEUkpq+4Ixg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/eventstream-serde-config-resolver": "^4.3.8", - "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", @@ -4599,18 +4904,32 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-dynamodb": { + "node_modules/@aws-sdk/client-athena/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.981.0.tgz", - "integrity": "sha512-QFM/3LbHzjydyKbuVA+oYPc3QCNH8hjui6Te/AaDO2waw/jLUklzItQZPHRQk6vMKnd7bNDUzNX0UiYTqrU5QA==", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.981.0.tgz", + "integrity": "sha512-pIkGmwKixO9WZlYob6NjFMOcx7syiBX7zuDOJvoWSOwBZKnrBZgoICm4cWW8x52ROw4d14O5vkjnHvRu+WuyoA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-node": "^3.972.4", - "@aws-sdk/dynamodb-codec": "^3.972.5", - "@aws-sdk/middleware-endpoint-discovery": "^3.972.3", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", @@ -4622,6 +4941,9 @@ "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", @@ -4645,18 +4967,18 @@ "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-dynamodb/node_modules/@aws-sdk/client-dynamodb": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.980.0.tgz", - "integrity": "sha512-1rGhAx4cHZy3pMB3R3r84qMT5WEvQ6ajr2UksnD48fjQxwaUcpI6NsPvU5j/5BI5LqGiUO6ThOrMwSMm95twQA==", + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.981.0.tgz", + "integrity": "sha512-QFM/3LbHzjydyKbuVA+oYPc3QCNH8hjui6Te/AaDO2waw/jLUklzItQZPHRQk6vMKnd7bNDUzNX0UiYTqrU5QA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4670,7 +4992,7 @@ "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-endpoints": "3.981.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", @@ -4705,62 +5027,26 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-dynamodb/node_modules/@aws-sdk/client-dynamodb/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-dynamodb/node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.5.tgz", - "integrity": "sha512-gFR4w3dIkaZ82kFFjil7RFtukS2y2fXrDNDfgc94DhKjjOQMJEcHM5o1GGaQE4jd2mOQfHvbeQ0ktU8xGXhHjQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@smithy/core": "^3.22.0", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-dynamodb": "3.980.0" - } - }, "node_modules/@aws-sdk/client-eventbridge": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.981.0.tgz", - "integrity": "sha512-ZXiLE8HA8CFxSZDTLt8PkrZd51bTrWKZH4Shj6F9LAQsDrx++tb4EFJ4NhoamsmrqEXZxQCYVriGmpnzhoBnhg==", + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.984.0.tgz", + "integrity": "sha512-8wNSeaCCpjI4JE95djl3UOSPdY2Xu8KlyHV6BIVTDM9tzfmF57zvGSGkwiWA+GmtJuwCCq7fhK54uXu8VyFMdQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/signature-v4-multi-region": "3.981.0", + "@aws-sdk/signature-v4-multi-region": "3.984.0", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", @@ -4792,25 +5078,58 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-eventbridge/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.984.0.tgz", + "integrity": "sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-eventbridge/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.981.0.tgz", - "integrity": "sha512-zLQpFFdKaXyARxpCuGcfax5ZpAguPo/G2uVCBJjDAycT7FU+spTPttV5dI49GQIo5pvU8yn+EaCVBJl89mZy1g==", + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.984.0.tgz", + "integrity": "sha512-kqwNBIGNxGVhINwgN/UQfdsQkaMjbu9PFV2EhATWouV+RT60uMjK9JENgLDwbgJmEVbbnPsh9HaZ5KKwPSdiDg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/eventstream-serde-browser": "^4.2.8", @@ -4847,12 +5166,27 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-lambda/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.981.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.981.0.tgz", "integrity": "sha512-zX3Xqm7V30J1D2II7WBL23SyqIIMD0wMzpiE+VosBxH6fAeXgrjIwSudCypNgnE1EK9OZoZMT3mJtkbUqUDdaA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -4915,25 +5249,25 @@ } }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.981.0.tgz", - "integrity": "sha512-wURPt0vup9NE/S2Jdti+Mpe4PlaLFc1CInMCMIEua2/XAbX4MDrMBJ1ZvEKiAvgj/GgptI2gGnmbLHc7w78XrQ==", + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.984.0.tgz", + "integrity": "sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-sqs": "^3.972.5", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", @@ -4966,25 +5300,41 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-ssm": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.981.0.tgz", - "integrity": "sha512-NOWbDJac1lHfknxqV+tPuNE9QxTCkH96VsWp8DnkPzxIOJJxCc/4fbzH+ugm6vZ67RICyRV1E8ZMMWhvFZrBfw==", + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.984.0.tgz", + "integrity": "sha512-YTC54r0XK7MdH627QoYIywd5/UjHUCwYeryqx+QmwmY0hgp+UxaBn9FS5LXiq7hJvYwnvsEr8cZPDHjK6JUvkQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", @@ -5017,24 +5367,40 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ssm/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sso": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.980.0.tgz", - "integrity": "sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.982.0.tgz", + "integrity": "sha512-qJrIiivmvujdGqJ0ldSUvhN3k3N7GtPesoOI1BSt0fNXovVnMz4C/JmnkhZihU7hJhDvxJaBROLYTU+lpild4w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-endpoints": "3.982.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", @@ -5067,9 +5433,9 @@ } }, "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", + "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -5083,13 +5449,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.5.tgz", - "integrity": "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.6.tgz", + "integrity": "sha512-pz4ZOw3BLG0NdF25HoB9ymSYyPbMiIjwQJ2aROXRhAzt+b+EOxStfFv8s5iZyP6Kiw7aYhyWxj5G3NhmkoOTKw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.2", + "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.22.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", @@ -5120,12 +5486,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.3.tgz", - "integrity": "sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.4.tgz", + "integrity": "sha512-/8dnc7+XNMmViEom2xsNdArQxQPSgy4Z/lm6qaFPTrMFesT1bV3PsBhb19n09nmxHdrtQskYmViddUIjUQElXg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", @@ -5136,12 +5502,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.5.tgz", - "integrity": "sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.6.tgz", + "integrity": "sha512-5ERWqRljiZv44AIdvIRQ3k+EAV0Sq2WeJHvXuK7gL7bovSxOf8Al7MLH7Eh3rdovH4KHFnlIty7J71mzvQBl5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.8", @@ -5157,19 +5523,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.3.tgz", - "integrity": "sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.4.tgz", + "integrity": "sha512-eRUg+3HaUKuXWn/lEMirdiA5HOKmEl8hEHVuszIDt2MMBUKgVX5XNGmb3XmbgU17h6DZ+RtjbxQpjhz3SbTjZg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/credential-provider-env": "^3.972.3", - "@aws-sdk/credential-provider-http": "^3.972.5", - "@aws-sdk/credential-provider-login": "^3.972.3", - "@aws-sdk/credential-provider-process": "^3.972.3", - "@aws-sdk/credential-provider-sso": "^3.972.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.3", - "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-env": "^3.972.4", + "@aws-sdk/credential-provider-http": "^3.972.6", + "@aws-sdk/credential-provider-login": "^3.972.4", + "@aws-sdk/credential-provider-process": "^3.972.4", + "@aws-sdk/credential-provider-sso": "^3.972.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.4", + "@aws-sdk/nested-clients": "3.982.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", @@ -5182,13 +5548,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.3.tgz", - "integrity": "sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.4.tgz", + "integrity": "sha512-nLGjXuvWWDlQAp505xIONI7Gam0vw2p7Qu3P6on/W2q7rjJXtYjtpHbcsaOjJ/pAju3eTvEQuSuRedcRHVQIAQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", @@ -5201,17 +5567,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.4.tgz", - "integrity": "sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.5.tgz", + "integrity": "sha512-VWXKgSISQCI2GKN3zakTNHSiZ0+mux7v6YHmmbLQp/o3fvYUQJmKGcLZZzg2GFA+tGGBStplra9VFNf/WwxpYg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.3", - "@aws-sdk/credential-provider-http": "^3.972.5", - "@aws-sdk/credential-provider-ini": "^3.972.3", - "@aws-sdk/credential-provider-process": "^3.972.3", - "@aws-sdk/credential-provider-sso": "^3.972.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.3", + "@aws-sdk/credential-provider-env": "^3.972.4", + "@aws-sdk/credential-provider-http": "^3.972.6", + "@aws-sdk/credential-provider-ini": "^3.972.4", + "@aws-sdk/credential-provider-process": "^3.972.4", + "@aws-sdk/credential-provider-sso": "^3.972.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.4", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", @@ -5224,12 +5590,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.3.tgz", - "integrity": "sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.4.tgz", + "integrity": "sha512-TCZpWUnBQN1YPk6grvd5x419OfXjHvhj5Oj44GYb84dOVChpg/+2VoEj+YVA4F4E/6huQPNnX7UYbTtxJqgihw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -5241,14 +5607,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.3.tgz", - "integrity": "sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.4.tgz", + "integrity": "sha512-wzsGwv9mKlwJ3vHLyembBvGE/5nPUIwRR2I51B1cBV4Cb4ql9nIIfpmHzm050XYTY5fqTOKJQnhLj7zj89VG8g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.980.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/token-providers": "3.980.0", + "@aws-sdk/client-sso": "3.982.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/token-providers": "3.982.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -5260,13 +5626,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.3.tgz", - "integrity": "sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.4.tgz", + "integrity": "sha512-hIzw2XzrG8jzsUSEatehmpkd5rWzASg5IHUfA+m01k/RtvfAML7ZJVVohuKdhAYx+wV2AThLiQJVzqn7F0khrw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -5277,6 +5643,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.7.tgz", + "integrity": "sha512-oo1g22IhIvZiBNTqV2DwBh39+pwo7Tz6aBZjA+eTNFJ1jfxD84tTycVPOwaYLb2ZYhgsxOT9bTAj/EHEPoKbLQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@smithy/core": "^3.22.0", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/endpoint-cache": { "version": "3.972.2", "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.2.tgz", @@ -5310,27 +5693,6 @@ "@aws-sdk/client-dynamodb": "3.981.0" } }, - "node_modules/@aws-sdk/lib-storage": { - "version": "3.981.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.981.0.tgz", - "integrity": "sha512-uNkWK1WthkgVglOSgu+0hjo/yVko73E7TbAL4BlAUYOG0AJZ105f0sJugoBILNgLrlRf70VQaPTjLGuUUQk8HQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/smithy-client": "^4.11.1", - "buffer": "5.6.0", - "events": "3.3.0", - "stream-browserify": "3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-s3": "3.981.0" - } - }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", @@ -5382,15 +5744,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.3.tgz", - "integrity": "sha512-MkNGJ6qB9kpsLwL18kC/ZXppsJbftHVGCisqpEVbTQsum8CLYDX1Bmp/IvhRGNxsqCO2w9/4PwhDKBjG3Uvr4Q==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.4.tgz", + "integrity": "sha512-xOxsUkF3O3BtIe3tf54OpPo94eZepjFm3z0Dd2TZKbsPxMiRTFXurC04wJ58o/wPW9YHVO9VqZik3MfoPfrKlw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/crc64-nvme": "3.972.0", "@aws-sdk/types": "^3.973.1", "@smithy/is-array-buffer": "^4.2.0", @@ -5466,12 +5828,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.5.tgz", - "integrity": "sha512-3IgeIDiQ15tmMBFIdJ1cTy3A9rXHGo+b9p22V38vA3MozeMyVC8VmCYdDLA0iMWo4VHA9LDJTgCM0+xU3wjBOg==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.6.tgz", + "integrity": "sha512-Xq7wM6kbgJN1UO++8dvH/efPb1nTwWqFCpZCR7RCLOETP7xAUAhVo7JmsCnML5Di/iC4Oo5VrJ4QmkYcMZniLw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/core": "^3.22.0", @@ -5522,14 +5884,14 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.5.tgz", - "integrity": "sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.6.tgz", + "integrity": "sha512-TehLN8W/kivl0U9HcS+keryElEWORROpghDXZBLfnb40DXM7hx/i+7OOjkogXQOF3QtUraJVRkHQ07bPhrWKlw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-endpoints": "3.982.0", "@smithy/core": "^3.22.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", @@ -5540,9 +5902,9 @@ } }, "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", + "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -5556,23 +5918,23 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.980.0.tgz", - "integrity": "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.982.0.tgz", + "integrity": "sha512-VVkaH27digrJfdVrT64rjkllvOp4oRiZuuJvrylLXAKl18ujToJR7AqpDldL/LS63RVne3QWIpkygIymxFtliQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", + "@aws-sdk/core": "^3.973.6", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-endpoints": "3.982.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", @@ -5605,9 +5967,9 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", + "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -5654,13 +6016,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.980.0.tgz", - "integrity": "sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA==", + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.982.0.tgz", + "integrity": "sha512-v3M0KYp2TVHYHNBT7jHD9lLTWAdS9CaWJ2jboRKt0WAB65bA7iUEpR+k4VqKYtpQN4+8kKSc4w+K6kUNZkHKQw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", @@ -5752,12 +6114,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.3.tgz", - "integrity": "sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.4.tgz", + "integrity": "sha512-3WFCBLiM8QiHDfosQq3Py+lIMgWlFWwFQliUHUqwEiRqLnKyhgbU3AKa7AWJF7lW2Oc/2kFNY4MlAYVnVc0i8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", @@ -5776,9 +6138,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.3.tgz", - "integrity": "sha512-bCk63RsBNCWW4tt5atv5Sbrh+3J3e8YzgyF6aZb1JeXcdzG4k5SlPLeTMFOIXFuuFHIwgphUhn4i3uS/q49eww==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", + "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -11325,13 +11687,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -19924,6 +20286,10 @@ "resolved": "lambdas/report-event-transformer", "link": true }, + "node_modules/nhs-notify-digital-letters-report-generator": { + "resolved": "lambdas/report-generator", + "link": true + }, "node_modules/nhs-notify-digital-letters-ttl-create-lambda": { "resolved": "lambdas/ttl-create-lambda", "link": true @@ -24526,14 +24892,15 @@ "utils/utils": { "version": "0.0.1", "dependencies": { - "@aws-sdk/client-dynamodb": "^3.914.0", - "@aws-sdk/client-eventbridge": "^3.918.0", - "@aws-sdk/client-lambda": "^3.914.0", - "@aws-sdk/client-s3": "^3.914.0", - "@aws-sdk/client-sqs": "^3.914.0", - "@aws-sdk/client-ssm": "^3.914.0", - "@aws-sdk/lib-dynamodb": "^3.914.0", - "@aws-sdk/lib-storage": "^3.914.0", + "@aws-sdk/client-athena": "^3.984.0", + "@aws-sdk/client-dynamodb": "^3.984.0", + "@aws-sdk/client-eventbridge": "^3.984.0", + "@aws-sdk/client-lambda": "^3.984.0", + "@aws-sdk/client-s3": "^3.984.0", + "@aws-sdk/client-sqs": "^3.984.0", + "@aws-sdk/client-ssm": "^3.984.0", + "@aws-sdk/lib-dynamodb": "^3.984.0", + "@aws-sdk/lib-storage": "^3.984.0", "async-mutex": "^0.4.0", "axios": "^1.13.2", "date-fns": "^4.1.0", @@ -24555,6 +24922,216 @@ "typescript": "^5.8.2" } }, + "utils/utils/node_modules/@aws-sdk/client-dynamodb": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.984.0.tgz", + "integrity": "sha512-8/Oft9MWQtbG6p9f8eY5fsKC2CcO5YVDlwive8eUYS9mEbgnyQxm68OyH26WvsSTykQ9QkIbR+fOG56RsIBODw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", + "@aws-sdk/dynamodb-codec": "^3.972.7", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.3", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.984.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "utils/utils/node_modules/@aws-sdk/client-s3": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.984.0.tgz", + "integrity": "sha512-7ny2Slr93Y+QniuluvcfWwyDi32zWQfznynL56Tk0vVh7bWrvS/odm8WP2nInKicRVNipcJHY2YInur6Q/9V0A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.4", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-location-constraint": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-s3": "^3.972.6", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.984.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.984.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "utils/utils/node_modules/@aws-sdk/lib-dynamodb": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.984.0.tgz", + "integrity": "sha512-kSPiRMFuDJUwCuvbCl5gl7dhS3CD0MBlazHFH9oLH8JEH33mcdCjS0mhnIgt9NIpfRe/X1waoVx6J2P6NMxcAw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/util-dynamodb": "3.984.0", + "@smithy/core": "^3.22.0", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.984.0" + } + }, + "utils/utils/node_modules/@aws-sdk/lib-storage": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.984.0.tgz", + "integrity": "sha512-fL/UqmEBs7esxRkBaCoYtuY5EuVK4HhVYffJwlXxHnzwziclVQ4XO5bjW/L7TTC6wqmZyMKdnjoI4wQfkNnijg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/smithy-client": "^4.11.1", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.984.0" + } + }, + "utils/utils/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.984.0.tgz", + "integrity": "sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "utils/utils/node_modules/@aws-sdk/util-dynamodb": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.984.0.tgz", + "integrity": "sha512-Yg3MbD3UJr/lKCtplbKxcCVxn2aaNURNB3ahaCaTAddMWLKik7aswW9zfZw1LQXuUHQeIBv27votDbp2YYY4aw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.984.0" + } + }, + "utils/utils/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "utils/utils/node_modules/@jest/core": { "version": "29.7.0", "dev": true, diff --git a/package.json b/package.json index cac36007..bf9e3da6 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "lambdas/print-analyser", "lambdas/report-event-transformer", "lambdas/move-scanned-files-lambda", + "lambdas/report-generator", "utils/utils", "utils/sender-management", "src/cloudevents", diff --git a/utils/utils/jest.config.ts b/utils/utils/jest.config.ts index a7b6eec8..172bcfa9 100644 --- a/utils/utils/jest.config.ts +++ b/utils/utils/jest.config.ts @@ -8,7 +8,7 @@ const utilsJestConfig = { branches: 85, functions: 85, lines: 85, - statements: -10, + statements: -50, }, }, diff --git a/utils/utils/package.json b/utils/utils/package.json index 5669cae8..d51a4306 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -1,13 +1,14 @@ { "dependencies": { - "@aws-sdk/client-dynamodb": "^3.914.0", - "@aws-sdk/client-eventbridge": "^3.918.0", - "@aws-sdk/client-lambda": "^3.914.0", - "@aws-sdk/client-s3": "^3.914.0", - "@aws-sdk/client-sqs": "^3.914.0", - "@aws-sdk/client-ssm": "^3.914.0", - "@aws-sdk/lib-dynamodb": "^3.914.0", - "@aws-sdk/lib-storage": "^3.914.0", + "@aws-sdk/client-athena": "^3.984.0", + "@aws-sdk/client-dynamodb": "^3.984.0", + "@aws-sdk/client-eventbridge": "^3.984.0", + "@aws-sdk/client-lambda": "^3.984.0", + "@aws-sdk/client-s3": "^3.984.0", + "@aws-sdk/client-sqs": "^3.984.0", + "@aws-sdk/client-ssm": "^3.984.0", + "@aws-sdk/lib-dynamodb": "^3.984.0", + "@aws-sdk/lib-storage": "^3.984.0", "async-mutex": "^0.4.0", "axios": "^1.13.2", "date-fns": "^4.1.0", diff --git a/utils/utils/src/index.ts b/utils/utils/src/index.ts index 8da5a845..4b9333bc 100644 --- a/utils/utils/src/index.ts +++ b/utils/utils/src/index.ts @@ -15,3 +15,4 @@ export * from './event-bridge-utils'; export * from './key-generation-utils'; export * from './schema-utils'; export * from './pdm-client'; +export * from './reporting'; diff --git a/utils/utils/src/reporting/data-repository.ts b/utils/utils/src/reporting/data-repository.ts new file mode 100644 index 00000000..da7bbad5 --- /dev/null +++ b/utils/utils/src/reporting/data-repository.ts @@ -0,0 +1,71 @@ +import { + AthenaClient, + GetQueryExecutionCommand, + StartQueryExecutionCommand, +} from '@aws-sdk/client-athena'; +import type { Logger } from '../logger'; + +export type DataRepositoryDependencies = { + athenaClient: AthenaClient; + config: Record; + logger: Logger; +}; + +export type IDataRepository = { + startQuery( + query: string, + executionParameters: string[], + ): Promise; + getQueryStatus(reportQueryId: string): Promise; +}; + +export class AthenaRepository implements IDataRepository { + readonly athenaClient: AthenaClient; + + readonly workGroup: string; + + readonly database: string; + + constructor(athenaClient: AthenaClient, config: Record) { + this.athenaClient = athenaClient; + this.workGroup = config.athenaWorkgroup; + this.database = config.athenaDatabase; + } + + /** + * Asynchronously starts a query execution in Athena. + * + * @param {string} query - The query string to execute. + * @return {Promise} - The ID of the query execution. + */ + async startQuery(query: string, executionParameters: string[]) { + const executionCommand = new StartQueryExecutionCommand({ + QueryString: query, + WorkGroup: this.workGroup, + QueryExecutionContext: { Database: this.database }, + ExecutionParameters: executionParameters, + }); + + const { QueryExecutionId } = await this.athenaClient.send(executionCommand); + + return QueryExecutionId; + } + + /** + * Retrieves the status of a query execution. + * + * @param {string} reportQueryId - The ID of the query execution. + * @return {State} The state of the query execution. + */ + async getQueryStatus(reportQueryId: string) { + const getQueryExecutionCommand = new GetQueryExecutionCommand({ + QueryExecutionId: reportQueryId, + }); + + const { QueryExecution } = await this.athenaClient.send( + getQueryExecutionCommand, + ); + + return QueryExecution?.Status?.State; + } +} diff --git a/utils/utils/src/reporting/index.ts b/utils/utils/src/reporting/index.ts new file mode 100644 index 00000000..c89877d1 --- /dev/null +++ b/utils/utils/src/reporting/index.ts @@ -0,0 +1,3 @@ +export * from './data-repository'; +export * from './report-service'; +export * from './storage-repository'; diff --git a/utils/utils/src/reporting/report-service.ts b/utils/utils/src/reporting/report-service.ts new file mode 100644 index 00000000..c92a36e1 --- /dev/null +++ b/utils/utils/src/reporting/report-service.ts @@ -0,0 +1,96 @@ +import type { Logger } from '../logger'; +import { sleep } from '../util-retry/sleep'; +import { IDataRepository } from './data-repository'; +import { IStorageRepository } from './storage-repository'; + +export interface IReportService { + generateReport( + query: string, + executionParameters: string[], + reportFilePath: string, + ): Promise; +} + +export class ReportService implements IReportService { + readonly dataRepository: IDataRepository; + + readonly storageRepository: IStorageRepository; + + readonly maxPollLimit: number; + + readonly waitForInSeconds: number; + + readonly logger: Logger; + + constructor( + dataRepository: IDataRepository, + storageRepository: IStorageRepository, + maxPollLimit: number, + waitForInSeconds: number, + logger: Logger, + ) { + this.dataRepository = dataRepository; + this.storageRepository = storageRepository; + this.maxPollLimit = maxPollLimit; + this.waitForInSeconds = waitForInSeconds; + this.logger = logger; + } + + async generateReport( + query: string, + executionParameters: string[], + reportFilePath: string, + ): Promise { + const queryExecutionId = await this.dataRepository.startQuery( + query, + executionParameters, + ); + + if (!queryExecutionId) { + throw new Error('failed to obtained a query executionId from Athena'); + } + + const logger = this.logger.child({ queryExecutionId }); + + logger.info(`Athena query started with execution id: ${queryExecutionId}`); + + const status = await this.poll( + queryExecutionId, + this.maxPollLimit, + this.waitForInSeconds, + ); + + if (status !== 'SUCCEEDED') { + throw new Error(`Failed to generate report. Query status: ${status}`); + } + + logger.info(`Athena query ${queryExecutionId} finished`); + + return this.storageRepository.publishReport( + queryExecutionId, + reportFilePath, + ); + } + + private async poll( + queryId: string, + maxPollLimit: number, + waitForInSeconds: number, + ) { + let count = 0; + let status = 'QUEUED'; + + while ( + count < maxPollLimit && + ['QUEUED', 'RUNNING', 'UNKNOWN'].includes(status) + ) { + status = (await this.dataRepository.getQueryStatus(queryId)) || 'UNKNOWN'; + + count += 1; + + await sleep(waitForInSeconds); + } + + return status; + } +} diff --git a/utils/utils/src/reporting/storage-repository.ts b/utils/utils/src/reporting/storage-repository.ts new file mode 100644 index 00000000..5a9444e6 --- /dev/null +++ b/utils/utils/src/reporting/storage-repository.ts @@ -0,0 +1,39 @@ +import { CopyObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { Logger } from '../logger'; + +export type IStorageRepository = { + publishReport: ( + reportQueryId: string, + reportFilePath: string, + ) => Promise; +}; + +type StorageRepositoryDependencies = { + s3Client: S3Client; + reportingBucketName: string; + logger: Logger; +}; + +export const createStorageRepository = ({ + logger, + reportingBucketName, + s3Client, +}: StorageRepositoryDependencies): IStorageRepository => ({ + async publishReport(reportQueryId: string, reportFilePath: string) { + logger.debug( + `Publishing report data to ${reportFilePath} for query ${reportQueryId}`, + ); + + const copyObjectCommand = new CopyObjectCommand({ + CopySource: `${reportingBucketName}/athena-output/${reportQueryId}.csv`, + Bucket: reportingBucketName, + Key: reportFilePath, + }); + + await s3Client.send(copyObjectCommand); + + logger.info(`Report stored at ${reportingBucketName}/${reportFilePath}.`); + + return `s3://${reportingBucketName}/${reportFilePath}`; + }, +}); From be5fef31aac6f52b328fe93514631a916580882e Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 11:27:31 +0000 Subject: [PATCH 03/19] CCM-13304: Fix existing test --- lambdas/report-generator/jest.config.ts | 6 +++--- lambdas/report-generator/src/container.ts | 6 +++++- .../src/__tests__/infra/dynamo-repository.test.ts | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lambdas/report-generator/jest.config.ts b/lambdas/report-generator/jest.config.ts index c23f9ba3..8fc4ec07 100644 --- a/lambdas/report-generator/jest.config.ts +++ b/lambdas/report-generator/jest.config.ts @@ -1,15 +1,15 @@ import { baseJestConfig } from '../../jest.config.base'; const config = { - ...baseJestConfig, - coverageThreshold: { + ...baseJestConfig, + coverageThreshold: { global: { branches: 0, functions: 0, lines: 0, statements: 0, }, - } + }, }; export default config; diff --git a/lambdas/report-generator/src/container.ts b/lambdas/report-generator/src/container.ts index 8fecf92a..947b71ef 100644 --- a/lambdas/report-generator/src/container.ts +++ b/lambdas/report-generator/src/container.ts @@ -47,7 +47,11 @@ export const createContainer = () => { logger, ); - const reportGenerator = new ReportGenerator(logger, reportService, reportName); + const reportGenerator = new ReportGenerator( + logger, + reportService, + reportName, + ); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, diff --git a/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts b/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts index 5f4d579f..e5d2eb41 100644 --- a/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts +++ b/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts @@ -20,7 +20,7 @@ const mockDynamoDeleteBatch = mockFn(); const dynamoRepository = new DynamoRepository( mockTableName, - dynamoDocumentClient, + mockDynamoClient as any, logger, mockDynamoDeleteBatch, ); From 5c2c8e87baa1bce3dad6fc6801443fd7e776b305 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 11:34:39 +0000 Subject: [PATCH 04/19] CCM-13304: Fix existing test --- .../src/__tests__/infra/dynamo-repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts b/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts index e5d2eb41..b978f78a 100644 --- a/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts +++ b/lambdas/ttl-poll-lambda/src/__tests__/infra/dynamo-repository.test.ts @@ -4,7 +4,7 @@ import { QueryCommand, } from '@aws-sdk/lib-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; -import { Logger, deleteDynamoBatch, dynamoDocumentClient } from 'utils'; +import { Logger, deleteDynamoBatch } from 'utils'; import { mock, mockFn } from 'jest-mock-extended'; import { DynamoRepository } from 'infra/dynamo-repository'; import 'aws-sdk-client-mock-jest'; From d753b4e47e38e2f5c0fd5e17c47fe55cd69a40a2 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 14:27:44 +0000 Subject: [PATCH 05/19] CCM-13304: Unit tests --- lambdas/report-generator/jest.config.ts | 17 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 274 ++++++++++++++++++ .../__tests__/app/report-generator.test.ts | 168 +++++++++++ .../src/__tests__/container.test.ts | 32 ++ .../src/__tests__/infra/config.test.ts | 15 + 5 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 lambdas/report-generator/src/__tests__/apis/sqs-trigger-lambda.test.ts create mode 100644 lambdas/report-generator/src/__tests__/app/report-generator.test.ts create mode 100644 lambdas/report-generator/src/__tests__/container.test.ts create mode 100644 lambdas/report-generator/src/__tests__/infra/config.test.ts diff --git a/lambdas/report-generator/jest.config.ts b/lambdas/report-generator/jest.config.ts index 8fc4ec07..43ec9858 100644 --- a/lambdas/report-generator/jest.config.ts +++ b/lambdas/report-generator/jest.config.ts @@ -1,14 +1,13 @@ import { baseJestConfig } from '../../jest.config.base'; -const config = { - ...baseJestConfig, - coverageThreshold: { - global: { - branches: 0, - functions: 0, - lines: 0, - statements: 0, - }, +const config = baseJestConfig; + +config.coverageThreshold = { + global: { + branches: 84, + functions: 100, + lines: 95, + statements: -10, }, }; diff --git a/lambdas/report-generator/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/report-generator/src/__tests__/apis/sqs-trigger-lambda.test.ts new file mode 100644 index 00000000..dca70fe2 --- /dev/null +++ b/lambdas/report-generator/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -0,0 +1,274 @@ +import { randomUUID } from 'node:crypto'; +import { GenerateReport } from 'digital-letters-events'; +import { EventPublisher, Logger } from 'utils'; +import type { SQSBatchResponse, SQSEvent, SQSRecord } from 'aws-lambda'; +import type { + ReportGenerator, + ReportGeneratorResult, +} from 'app/report-generator'; +import { createHandler } from 'apis/sqs-trigger-lambda'; + +jest.mock('node:crypto'); + +const mockUuid = '123e4567-e89b-12d3-a456-426614174000'; + +const createMockSQSRecord = ( + messageId: string, + event: Partial, +): SQSRecord => ({ + messageId, + receiptHandle: 'receipt-handle', + body: JSON.stringify({ + detail: { + id: event.id || mockUuid, + source: + event.source || + '/nhs/england/notify/development/primary/data-plane/digitalletters/reporting', + specversion: event.specversion || '1.0', + type: + event.type || + 'uk.nhs.notify.digital.letters.reporting.generate.report.v1', + dataschema: + event.dataschema || + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.json', + time: event.time || new Date().toISOString(), + recordedtime: event.recordedtime || new Date().toISOString(), + subject: event.subject || 'customer/5661de82-7453-44a1-9922-e0c98e5411c1', + traceparent: + event.traceparent || + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + severitynumber: event.severitynumber || 2, + data: event.data || { + senderId: 'fce3ddee-2aca-4b2e-90a8-ce4da3787792', + reportDate: '2025-01-01', + }, + }, + }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'us-east-1', +}); + +const createMockSQSEvent = (records: SQSRecord[]): SQSEvent => ({ + Records: records, +}); + +describe('sqs-trigger-lambda', () => { + let mockReportGenerator: jest.Mocked; + let mockEventPublisher: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + (randomUUID as jest.Mock).mockReturnValue(mockUuid); + + mockReportGenerator = { + generate: jest.fn(), + } as unknown as jest.Mocked; + + mockEventPublisher = { + sendEvents: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + + mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe('createHandler', () => { + it('should process valid SQS records successfully', async () => { + const reportUri = 's3://bucket/report.csv'; + const mockResult: ReportGeneratorResult = { + outcome: 'generated', + reportUri, + }; + mockReportGenerator.generate.mockResolvedValue(mockResult); + + const record = createMockSQSRecord('msg-1', {}); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + const response: SQSBatchResponse = await handler(sqsEvent); + + expect(response.batchItemFailures).toEqual([]); + expect(mockReportGenerator.generate).toHaveBeenCalledTimes(1); + expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Processed SQS Event.', + retrieved: 1, + generated: 1, + failed: 0, + }), + ); + }); + + it('should handle invalid JSON in SQS record body', async () => { + const sqsEvent: SQSEvent = { + Records: [ + { + ...createMockSQSRecord('msg-1', {}), + body: 'invalid-json', + }, + ], + }; + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + const response = await handler(sqsEvent); + + expect(response.batchItemFailures).toEqual([{ itemIdentifier: 'msg-1' }]); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing SQS record', + }), + ); + expect(mockReportGenerator.generate).not.toHaveBeenCalled(); + }); + + it('should handle validation failure for event schema', async () => { + const record = createMockSQSRecord('msg-1', { + type: 'invalid-type' as any, + data: {} as any, + }); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + const response = await handler(sqsEvent); + + expect(response.batchItemFailures).toEqual([{ itemIdentifier: 'msg-1' }]); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing queue entry', + }), + ); + expect(mockReportGenerator.generate).not.toHaveBeenCalled(); + }); + + it('should add to batch failures when report generation fails', async () => { + const mockResult: ReportGeneratorResult = { outcome: 'failed' }; + mockReportGenerator.generate.mockResolvedValue(mockResult); + + const record = createMockSQSRecord('msg-1', {}); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + const response = await handler(sqsEvent); + + expect(response.batchItemFailures).toEqual([{ itemIdentifier: 'msg-1' }]); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + failed: 1, + generated: 0, + }), + ); + }); + + it('should handle exceptions during report generation', async () => { + const error = new Error('Generation error'); + mockReportGenerator.generate.mockRejectedValue(error); + + const record = createMockSQSRecord('msg-1', {}); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + const response = await handler(sqsEvent); + + expect(response.batchItemFailures).toEqual([{ itemIdentifier: 'msg-1' }]); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: error, + description: 'Error during SQS trigger handler', + }), + ); + }); + + it('should not publish events when there are no successful items', async () => { + mockReportGenerator.generate.mockResolvedValue({ outcome: 'failed' }); + + const record = createMockSQSRecord('msg-1', { + data: { senderId: 'sender-123' } as any, + }); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + await handler(sqsEvent); + + expect(mockEventPublisher.sendEvents).not.toHaveBeenCalled(); + }); + + it('should generate correct ReportGenerated events', async () => { + const reportUri = 's3://bucket/report.csv'; + mockReportGenerator.generate.mockResolvedValue({ + outcome: 'generated', + reportUri, + }); + + const record = createMockSQSRecord('msg-1', {}); + const sqsEvent = createMockSQSEvent([record]); + + const handler = createHandler({ + reportGenerator: mockReportGenerator, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + + await handler(sqsEvent); + + expect(mockEventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: mockUuid, + type: 'uk.nhs.notify.digital.letters.reporting.report.generated.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-reporting-report-generated-data.schema.json', + data: { + senderId: 'fce3ddee-2aca-4b2e-90a8-ce4da3787792', + reportUri, + }, + }), + ], + expect.any(Function), + ); + }); + }); +}); diff --git a/lambdas/report-generator/src/__tests__/app/report-generator.test.ts b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts new file mode 100644 index 00000000..7a9752d6 --- /dev/null +++ b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts @@ -0,0 +1,168 @@ +import { IReportService, Logger } from 'utils'; +import { GenerateReport } from 'digital-letters-events'; +import fs from 'node:fs'; +import { ReportGenerator } from 'app/report-generator'; + +jest.mock('node:fs'); + +describe('ReportGenerator', () => { + let mockLogger: jest.Mocked; + let mockReportService: jest.Mocked; + let reportGenerator: ReportGenerator; + const reportName = 'daily-summary'; + + beforeEach(() => { + mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + + mockReportService = { + generateReport: jest.fn(), + } as jest.Mocked; + + reportGenerator = new ReportGenerator( + mockLogger, + mockReportService, + reportName, + ); + + jest.clearAllMocks(); + }); + + describe('generate', () => { + const mockEvent: GenerateReport = { + data: { + senderId: 'sender-123', + reportDate: '2025-01-15', + }, + specversion: '1.0', + type: 'uk.nhs.notify.digital.letters.reporting.generate.report.v1', + source: 'test', + id: 'test-id', + time: '2025-01-15T10:00:00Z', + datacontenttype: 'application/json', + subject: 'customer/5661de82-7453-44a1-9922-e0c98e5411c1', + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + recordedtime: '2025-12-15T10:00:00Z', + severitynumber: 2, + }; + + const mockQuery = + 'SELECT * FROM reports WHERE date = $1 AND sender_id = $2'; + + beforeEach(() => { + (fs.readFileSync as jest.Mock).mockReturnValue(mockQuery); + }); + + it('should successfully generate a report', async () => { + const expectedLocation = + 's3://bucket/reports/sender-123/daily-summary/daily-summary_sender-123_2025-01-15.csv'; + mockReportService.generateReport.mockResolvedValue(expectedLocation); + + const result = await reportGenerator.generate(mockEvent); + + expect(fs.readFileSync).toHaveBeenCalledWith( + '/var/task/queries/report.sql', + 'utf8', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Generating report for sender sender-123 and date 2025-01-15', + ); + expect(mockReportService.generateReport).toHaveBeenCalledWith( + mockQuery, + ['2025-01-15', 'sender-123'], + 'reports/sender-123/daily-summary/daily-summary_sender-123_2025-01-15.csv', + ); + expect(result).toEqual({ + outcome: 'generated', + reportUri: expectedLocation, + }); + }); + + it('should construct correct report file path with report name', async () => { + const expectedLocation = + 's3://bucket/reports/sender-456/daily-summary/daily-summary_sender-456_2025-02-20.csv'; + mockReportService.generateReport.mockResolvedValue(expectedLocation); + + const customEvent: GenerateReport = { + ...mockEvent, + data: { + senderId: 'sender-456', + reportDate: '2025-02-20', + }, + }; + + await reportGenerator.generate(customEvent); + + expect(mockReportService.generateReport).toHaveBeenCalledWith( + expect.any(String), + ['2025-02-20', 'sender-456'], + 'reports/sender-456/daily-summary/daily-summary_sender-456_2025-02-20.csv', + ); + }); + + it('should pass query parameters in correct order', async () => { + mockReportService.generateReport.mockResolvedValue( + 's3://bucket/report.csv', + ); + + await reportGenerator.generate(mockEvent); + + expect(mockReportService.generateReport).toHaveBeenCalledWith( + mockQuery, + ['2025-01-15', 'sender-123'], + expect.any(String), + ); + }); + + it('should return failed outcome when report service throws error', async () => { + const error = new Error('Database connection failed'); + mockReportService.generateReport.mockRejectedValue(error); + + const result = await reportGenerator.generate(mockEvent); + + expect(mockLogger.error).toHaveBeenCalledWith({ + err: error, + description: 'Error generating report', + senderId: 'sender-123', + reportDate: '2025-01-15', + }); + expect(result).toEqual({ + outcome: 'failed', + }); + }); + + it('should return failed outcome when file read throws error', async () => { + const error = new Error('File not found'); + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw error; + }); + + const result = await reportGenerator.generate(mockEvent); + + expect(mockLogger.error).toHaveBeenCalledWith({ + err: error, + description: 'Error generating report', + senderId: 'sender-123', + reportDate: '2025-01-15', + }); + expect(result).toEqual({ + outcome: 'failed', + }); + }); + + it('should not return reportUri when report generation fails', async () => { + mockReportService.generateReport.mockRejectedValue( + new Error('S3 upload failed'), + ); + + const result = await reportGenerator.generate(mockEvent); + + expect(result.reportUri).toBeUndefined(); + expect(result.outcome).toBe('failed'); + }); + }); +}); diff --git a/lambdas/report-generator/src/__tests__/container.test.ts b/lambdas/report-generator/src/__tests__/container.test.ts new file mode 100644 index 00000000..cbea0df4 --- /dev/null +++ b/lambdas/report-generator/src/__tests__/container.test.ts @@ -0,0 +1,32 @@ +import { createContainer } from 'container'; + +jest.mock('infra/config', () => ({ + loadConfig: jest.fn(() => ({ + athenaDatabase: 'test-database', + athenaWorkgroup: 'test-workgroup', + eventPublisherDlqUrl: 'test-url', + eventPublisherEventBusArn: 'test-arn', + maxPollLimit: 10, + reportName: 'test-report', + reportingBucket: 'test-bucket', + waitForInSeconds: 5, + })), +})); + +jest.mock('utils', () => ({ + AthenaRepository: jest.fn(() => ({})), + ReportService: jest.fn(() => ({})), + createStorageRepository: jest.fn(() => ({})), + s3Client: {}, + eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), + logger: {}, + sqsClient: {}, +})); + +describe('container', () => { + it('should create container', () => { + const container = createContainer(); + expect(container).toBeDefined(); + }); +}); diff --git a/lambdas/report-generator/src/__tests__/infra/config.test.ts b/lambdas/report-generator/src/__tests__/infra/config.test.ts new file mode 100644 index 00000000..2902c80f --- /dev/null +++ b/lambdas/report-generator/src/__tests__/infra/config.test.ts @@ -0,0 +1,15 @@ +import { loadConfig } from 'infra/config'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn(), + getInt: jest.fn(), + }, +})); + +describe('config', () => { + it('should load config', () => { + const config = loadConfig(); + expect(config).toBeDefined(); + }); +}); From b3cd51d62a39d0946bee7ec65a1a55fc3c126c77 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 14:29:18 +0000 Subject: [PATCH 06/19] CCM-13304: Unit tests --- utils/utils/jest.config.ts | 2 +- .../reporting/data-repository.test.ts | 152 +++++++++++++ .../reporting/report-service.test.ts | 201 ++++++++++++++++++ .../reporting/storage-repository.test.ts | 76 +++++++ 4 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 utils/utils/src/__tests__/reporting/data-repository.test.ts create mode 100644 utils/utils/src/__tests__/reporting/report-service.test.ts create mode 100644 utils/utils/src/__tests__/reporting/storage-repository.test.ts diff --git a/utils/utils/jest.config.ts b/utils/utils/jest.config.ts index 172bcfa9..a7b6eec8 100644 --- a/utils/utils/jest.config.ts +++ b/utils/utils/jest.config.ts @@ -8,7 +8,7 @@ const utilsJestConfig = { branches: 85, functions: 85, lines: 85, - statements: -50, + statements: -10, }, }, diff --git a/utils/utils/src/__tests__/reporting/data-repository.test.ts b/utils/utils/src/__tests__/reporting/data-repository.test.ts new file mode 100644 index 00000000..4a9f13c2 --- /dev/null +++ b/utils/utils/src/__tests__/reporting/data-repository.test.ts @@ -0,0 +1,152 @@ +import { AthenaClient } from '@aws-sdk/client-athena'; +import { mockClient } from 'aws-sdk-client-mock'; +import { AthenaRepository } from '../../reporting/data-repository'; + +const athenaClientMock = mockClient(AthenaClient); + +describe('AthenaRepository', () => { + let repository: AthenaRepository; + const mockConfig = { + athenaWorkgroup: 'test-workgroup', + athenaDatabase: 'test-database', + }; + + beforeEach(() => { + athenaClientMock.reset(); + repository = new AthenaRepository(new AthenaClient({}), mockConfig); + }); + + describe('constructor', () => { + it('should initialize with correct workgroup and database', () => { + expect(repository.workGroup).toBe('test-workgroup'); + expect(repository.database).toBe('test-database'); + }); + }); + + describe('startQuery', () => { + it('should start query execution and return query execution ID', async () => { + const mockQueryExecutionId = 'query-123'; + athenaClientMock.onAnyCommand().resolves({ + QueryExecutionId: mockQueryExecutionId, + }); + + const result = await repository.startQuery('SELECT * FROM table', [ + 'param1', + 'param2', + ]); + + expect(result).toBe(mockQueryExecutionId); + }); + + it('should send correct parameters to Athena client', async () => { + const query = 'SELECT * FROM table WHERE id = ?'; + const executionParameters = ['123']; + + athenaClientMock.onAnyCommand().resolves({ + QueryExecutionId: 'query-456', + }); + + await repository.startQuery(query, executionParameters); + + const calls = athenaClientMock.commandCalls( + Object.getPrototypeOf(athenaClientMock.calls()[0].args[0]).constructor, + ); + expect(calls[0].args[0].input).toEqual({ + QueryString: query, + WorkGroup: 'test-workgroup', + QueryExecutionContext: { Database: 'test-database' }, + ExecutionParameters: executionParameters, + }); + }); + + it('should handle empty execution parameters', async () => { + athenaClientMock.onAnyCommand().resolves({ + QueryExecutionId: 'query-789', + }); + + const result = await repository.startQuery('SELECT 1', []); + + expect(result).toBe('query-789'); + }); + + it('should propagate Athena client errors', async () => { + const mockError = new Error('Athena service error'); + athenaClientMock.onAnyCommand().rejects(mockError); + + await expect( + repository.startQuery('SELECT * FROM table', []), + ).rejects.toThrow('Athena service error'); + }); + }); + + describe('getQueryStatus', () => { + it('should return query execution state', async () => { + athenaClientMock.onAnyCommand().resolves({ + QueryExecution: { + Status: { + State: 'SUCCEEDED', + }, + }, + }); + + const result = await repository.getQueryStatus('query-123'); + + expect(result).toBe('SUCCEEDED'); + }); + + it('should handle RUNNING state', async () => { + athenaClientMock.onAnyCommand().resolves({ + QueryExecution: { + Status: { + State: 'RUNNING', + }, + }, + }); + + const result = await repository.getQueryStatus('query-456'); + + expect(result).toBe('RUNNING'); + }); + + it('should handle FAILED state', async () => { + athenaClientMock.onAnyCommand().resolves({ + QueryExecution: { + Status: { + State: 'FAILED', + }, + }, + }); + + const result = await repository.getQueryStatus('query-789'); + + expect(result).toBe('FAILED'); + }); + + it('should return undefined when QueryExecution is missing', async () => { + athenaClientMock.onAnyCommand().resolves({}); + + const result = await repository.getQueryStatus('query-999'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when Status is missing', async () => { + athenaClientMock.onAnyCommand().resolves({ + QueryExecution: {}, + }); + + const result = await repository.getQueryStatus('query-888'); + + expect(result).toBeUndefined(); + }); + + it('should propagate Athena client errors', async () => { + const mockError = new Error('Query not found'); + athenaClientMock.onAnyCommand().rejects(mockError); + + await expect(repository.getQueryStatus('invalid-query')).rejects.toThrow( + 'Query not found', + ); + }); + }); +}); diff --git a/utils/utils/src/__tests__/reporting/report-service.test.ts b/utils/utils/src/__tests__/reporting/report-service.test.ts new file mode 100644 index 00000000..8f84424e --- /dev/null +++ b/utils/utils/src/__tests__/reporting/report-service.test.ts @@ -0,0 +1,201 @@ +import { Logger } from '../../logger'; +import { ReportService } from '../../reporting/report-service'; +import { IDataRepository } from '../../reporting/data-repository'; +import { IStorageRepository } from '../../reporting/storage-repository'; +import { sleep } from '../../util-retry/sleep'; + +jest.mock('../../util-retry/sleep'); + +describe('ReportService', () => { + let mockDataRepository: jest.Mocked; + let mockStorageRepository: jest.Mocked; + let mockLogger: jest.Mocked; + let reportService: ReportService; + + const defaultMaxPollLimit = 10; + const defaultWaitForInSeconds = 1; + + beforeEach(() => { + mockDataRepository = { + startQuery: jest.fn(), + getQueryStatus: jest.fn(), + } as jest.Mocked; + + mockStorageRepository = { + publishReport: jest.fn(), + } as jest.Mocked; + + mockLogger = { + child: jest.fn().mockReturnThis(), + info: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + reportService = new ReportService( + mockDataRepository, + mockStorageRepository, + defaultMaxPollLimit, + defaultWaitForInSeconds, + mockLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateReport', () => { + const query = 'SELECT * FROM test_table'; + const executionParameters = ['param1', 'param2']; + const reportFilePath = 's3://bucket/report.csv'; + const queryExecutionId = 'test-execution-id-123'; + + it('should generate report successfully when query succeeds', async () => { + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus.mockResolvedValue('SUCCEEDED'); + mockStorageRepository.publishReport.mockResolvedValue(reportFilePath); + + const result = await reportService.generateReport( + query, + executionParameters, + reportFilePath, + ); + + expect(mockDataRepository.startQuery).toHaveBeenCalledWith( + query, + executionParameters, + ); + expect(mockDataRepository.getQueryStatus).toHaveBeenCalledWith( + queryExecutionId, + ); + expect(mockStorageRepository.publishReport).toHaveBeenCalledWith( + queryExecutionId, + reportFilePath, + ); + expect(result).toBe(reportFilePath); + expect(mockLogger.child).toHaveBeenCalledWith({ queryExecutionId }); + expect(mockLogger.info).toHaveBeenCalledWith( + `Athena query started with execution id: ${queryExecutionId}`, + ); + expect(mockLogger.info).toHaveBeenCalledWith( + `Athena query ${queryExecutionId} finished`, + ); + }); + + it('should throw error when query fails', async () => { + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus.mockResolvedValue('FAILED'); + + await expect( + reportService.generateReport( + query, + executionParameters, + reportFilePath, + ), + ).rejects.toThrow('Failed to generate report. Query status: FAILED'); + + expect(mockStorageRepository.publishReport).not.toHaveBeenCalled(); + }); + + it('should throw error when query is cancelled', async () => { + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus.mockResolvedValue('CANCELLED'); + + await expect( + reportService.generateReport( + query, + executionParameters, + reportFilePath, + ), + ).rejects.toThrow('Failed to generate report. Query status: CANCELLED'); + }); + + it('should poll until query succeeds', async () => { + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus + .mockResolvedValueOnce('QUEUED') + .mockResolvedValueOnce('RUNNING') + .mockResolvedValueOnce('RUNNING') + .mockResolvedValueOnce('SUCCEEDED'); + mockStorageRepository.publishReport.mockResolvedValue(reportFilePath); + + await reportService.generateReport( + query, + executionParameters, + reportFilePath, + ); + + expect(mockDataRepository.getQueryStatus).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledWith(defaultWaitForInSeconds); + }); + + it('should throw error when max poll limit is reached', async () => { + const shortPollLimit = 3; + const shortReportService = new ReportService( + mockDataRepository, + mockStorageRepository, + shortPollLimit, + defaultWaitForInSeconds, + mockLogger, + ); + + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus.mockResolvedValue('RUNNING'); + + await expect( + shortReportService.generateReport( + query, + executionParameters, + reportFilePath, + ), + ).rejects.toThrow('Failed to generate report. Query status: RUNNING'); + + expect(mockDataRepository.getQueryStatus).toHaveBeenCalledTimes( + shortPollLimit, + ); + }); + + it('should handle UNKNOWN status and continue polling', async () => { + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus + .mockResolvedValueOnce('QUEUED') + .mockResolvedValueOnce('UNKNOWN') + .mockResolvedValueOnce('SUCCEEDED'); + mockStorageRepository.publishReport.mockResolvedValue(reportFilePath); + + await reportService.generateReport( + query, + executionParameters, + reportFilePath, + ); + + expect(mockDataRepository.getQueryStatus).toHaveBeenCalledTimes(3); + }); + + it('should respect custom wait time between polls', async () => { + const customWaitTime = 5; + const customReportService = new ReportService( + mockDataRepository, + mockStorageRepository, + defaultMaxPollLimit, + customWaitTime, + mockLogger, + ); + + mockDataRepository.startQuery.mockResolvedValue(queryExecutionId); + mockDataRepository.getQueryStatus + .mockResolvedValueOnce('RUNNING') + .mockResolvedValueOnce('SUCCEEDED'); + mockStorageRepository.publishReport.mockResolvedValue(reportFilePath); + + await customReportService.generateReport( + query, + executionParameters, + reportFilePath, + ); + + expect(sleep).toHaveBeenCalledWith(customWaitTime); + }); + }); +}); diff --git a/utils/utils/src/__tests__/reporting/storage-repository.test.ts b/utils/utils/src/__tests__/reporting/storage-repository.test.ts new file mode 100644 index 00000000..4fb65e96 --- /dev/null +++ b/utils/utils/src/__tests__/reporting/storage-repository.test.ts @@ -0,0 +1,76 @@ +import { CopyObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { createStorageRepository } from '../../reporting/storage-repository'; +import { Logger } from '../../logger'; + +const s3Mock = mockClient(S3Client); + +describe('StorageRepository', () => { + const mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + } as unknown as jest.Mocked; + const reportingBucketName = 'test-reporting-bucket'; + let storageRepository: ReturnType; + + beforeEach(() => { + s3Mock.reset(); + storageRepository = createStorageRepository({ + s3Client: new S3Client({}), + reportingBucketName, + logger: mockLogger, + }); + }); + + describe('publishReport', () => { + it('should copy report from athena-output to target path', async () => { + const reportQueryId = 'query-123'; + const reportFilePath = 'reports/2024/report.csv'; + + s3Mock.on(CopyObjectCommand).resolves({}); + + const result = await storageRepository.publishReport( + reportQueryId, + reportFilePath, + ); + + expect(result).toBe(`s3://${reportingBucketName}/${reportFilePath}`); + expect(s3Mock.calls()).toHaveLength(1); + + const copyCommand = s3Mock.call(0).args[0].input; + expect(copyCommand).toEqual({ + CopySource: `${reportingBucketName}/athena-output/${reportQueryId}.csv`, + Bucket: reportingBucketName, + Key: reportFilePath, + }); + }); + + it('should throw error when S3 copy fails', async () => { + const reportQueryId = 'query-456'; + const reportFilePath = 'reports/2024/failed-report.csv'; + const s3Error = new Error('S3 CopyObject failed'); + + s3Mock.on(CopyObjectCommand).rejects(s3Error); + + await expect( + storageRepository.publishReport(reportQueryId, reportFilePath), + ).rejects.toThrow('S3 CopyObject failed'); + }); + + it('should construct correct S3 URIs for nested paths', async () => { + const reportQueryId = 'query-789'; + const reportFilePath = 'reports/2024/01/daily/report.csv'; + + s3Mock.on(CopyObjectCommand).resolves({}); + + const result = await storageRepository.publishReport( + reportQueryId, + reportFilePath, + ); + + expect(result).toBe(`s3://${reportingBucketName}/${reportFilePath}`); + }); + }); +}); From 83fc83e6628c13448d395dc30d41e4f4d7378436 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Tue, 10 Feb 2026 14:48:56 +0000 Subject: [PATCH 07/19] CCM-13304: Fix terraform --- .../terraform/components/dl/module_lambda_report_generator.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf index 9741fa79..39f610eb 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -40,7 +40,7 @@ module "report_generator" { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "MAX_POLL_LIMIT" = var.athena_query_max_polling_attemps - "REPORTING_BUCKET" = s3bucket_reporting.name + "REPORTING_BUCKET" = module.s3bucket_reporting.bucket "REPORT_NAME" = "completed_communications" "WAIT_FOR_IN_SECONDS" = var.athena_query_polling_time_seconds } From 6ab35e249f64bab35da7d6860f6566579c95d9e1 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 08:50:51 +0000 Subject: [PATCH 08/19] CCM-13304: Bundle SQL in lambda --- lambdas/report-generator/package.json | 2 +- lambdas/report-generator/src/app/report-generator.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/report-generator/package.json b/lambdas/report-generator/package.json index 68bbff40..34b50efc 100644 --- a/lambdas/report-generator/package.json +++ b/lambdas/report-generator/package.json @@ -14,7 +14,7 @@ "name": "nhs-notify-digital-letters-report-generator", "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", + "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 && cp -r src/queries dist/", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", diff --git a/lambdas/report-generator/src/app/report-generator.ts b/lambdas/report-generator/src/app/report-generator.ts index a211ad26..6bba6d9a 100644 --- a/lambdas/report-generator/src/app/report-generator.ts +++ b/lambdas/report-generator/src/app/report-generator.ts @@ -21,7 +21,7 @@ export class ReportGenerator { const query = fs.readFileSync('/var/task/queries/report.sql', 'utf8'); const { senderId } = event.data; const { reportDate } = event.data; - const reportFilePath = `reports/${senderId}/${this.reportName}/${this.reportName}_${senderId}_${reportDate}.csv`; + const reportFilePath = `transactional-reports/${senderId}/${this.reportName}/${this.reportName}_${reportDate}.csv`; this.logger.info( `Generating report for sender ${senderId} and date ${reportDate}`, From 4c987cb56801215130a6eafab6c82f9954ed39b9 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 08:58:58 +0000 Subject: [PATCH 09/19] CCM-13304: Bundle SQL in lambda --- .../src/__tests__/app/report-generator.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lambdas/report-generator/src/__tests__/app/report-generator.test.ts b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts index 7a9752d6..b0c3ace4 100644 --- a/lambdas/report-generator/src/__tests__/app/report-generator.test.ts +++ b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts @@ -9,7 +9,7 @@ describe('ReportGenerator', () => { let mockLogger: jest.Mocked; let mockReportService: jest.Mocked; let reportGenerator: ReportGenerator; - const reportName = 'daily-summary'; + const reportName = 'completed_communications'; beforeEach(() => { mockLogger = { @@ -59,7 +59,7 @@ describe('ReportGenerator', () => { it('should successfully generate a report', async () => { const expectedLocation = - 's3://bucket/reports/sender-123/daily-summary/daily-summary_sender-123_2025-01-15.csv'; + 's3://bucket/reports/sender-123/completed_communications/completed_communications_sender-123_2025-01-15.csv'; mockReportService.generateReport.mockResolvedValue(expectedLocation); const result = await reportGenerator.generate(mockEvent); @@ -74,7 +74,7 @@ describe('ReportGenerator', () => { expect(mockReportService.generateReport).toHaveBeenCalledWith( mockQuery, ['2025-01-15', 'sender-123'], - 'reports/sender-123/daily-summary/daily-summary_sender-123_2025-01-15.csv', + 'transactional-reports/sender-123/completed_communications/completed_communications_2025-01-15.csv', ); expect(result).toEqual({ outcome: 'generated', @@ -84,7 +84,7 @@ describe('ReportGenerator', () => { it('should construct correct report file path with report name', async () => { const expectedLocation = - 's3://bucket/reports/sender-456/daily-summary/daily-summary_sender-456_2025-02-20.csv'; + 's3://bucket/transactional-reports/sender-123/completed_communications/completed_communications_2025-01-15.csv'; mockReportService.generateReport.mockResolvedValue(expectedLocation); const customEvent: GenerateReport = { @@ -100,7 +100,7 @@ describe('ReportGenerator', () => { expect(mockReportService.generateReport).toHaveBeenCalledWith( expect.any(String), ['2025-02-20', 'sender-456'], - 'reports/sender-456/daily-summary/daily-summary_sender-456_2025-02-20.csv', + 'transactional-reports/sender-456/completed_communications/completed_communications_2025-02-20.csv', ); }); From 0a9383de4cf4afe29d4ee3c087e5980a97d2a8fe Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 13:17:40 +0000 Subject: [PATCH 10/19] CCM-13304: Add permission to run Athen queries --- infrastructure/terraform/components/dl/locals.tf | 1 + .../dl/module_lambda_report_generator.tf | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index 301f11f0..dea77baa 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -4,6 +4,7 @@ locals { apim_keystore_s3_bucket = "nhs-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-static-assets" apim_private_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/private_key" athena_reporting_database = "${local.csi}-reporting" + athena_workgroup_arn = "arn:aws:athena:${var.region}:${var.shared_infra_account_id}:workgroup/${aws_athena_workgroup.reporting.name}" aws_lambda_functions_dir_path = "../../../../lambdas" deploy_pdm_mock = var.enable_pdm_mock firehose_output_path_prefix = "kinesis-firehose-output" diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf index 39f610eb..c5b96d38 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -76,6 +76,21 @@ data "aws_iam_policy_document" "report_generator_lambda" { ] } + statement { + sid = "AllowAthenaAccess" + effect = "Allow" + + actions = [ + "athena:StartQueryExecution", + "athena:GetQueryResults", + "athena:GetQueryExecution" + ] + + resources = [ + local.athena_workgroup_arn + ] + } + statement { sid = "SQSPermissionsReportGeneratorQueue" effect = "Allow" From 52dd47a6ca7289bc29dada1e977d90d96cea2bf5 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 13:28:01 +0000 Subject: [PATCH 11/19] CCM-13304: Add letter status to athena table --- .../components/dl/glue_catalog_table_event_record.tf | 4 ++++ .../src/__tests__/apis/firehose-handler.test.ts | 1 + lambdas/report-event-transformer/src/__tests__/test-data.ts | 1 + lambdas/report-event-transformer/src/apis/firehose-handler.ts | 3 ++- lambdas/report-event-transformer/src/types/events.ts | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf index f03c9586..c5bccad3 100644 --- a/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf +++ b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf @@ -36,6 +36,10 @@ resource "aws_glue_catalog_table" "event_record" { name = "type" type = "string" } + columns { + name = "type" + type = "letterstatus" + } } partition_keys { diff --git a/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts b/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts index 10942086..f1babbc4 100644 --- a/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts +++ b/lambdas/report-event-transformer/src/__tests__/apis/firehose-handler.test.ts @@ -49,6 +49,7 @@ describe('Firehose Handler', () => { senderId: digitalLettersEvent.data.senderId, pageCount: digitalLettersEvent.data.pageCount, supplierId: digitalLettersEvent.data.supplierId, + letterStatus: digitalLettersEvent.data.status, time: digitalLettersEvent.time, type: digitalLettersEvent.type, }); diff --git a/lambdas/report-event-transformer/src/__tests__/test-data.ts b/lambdas/report-event-transformer/src/__tests__/test-data.ts index e8043885..91cacb2a 100644 --- a/lambdas/report-event-transformer/src/__tests__/test-data.ts +++ b/lambdas/report-event-transformer/src/__tests__/test-data.ts @@ -21,6 +21,7 @@ const baseEvent = { resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09', messageReference: 'ref1', senderId: 'sender1', + status: 'DISPATCHED', }, }; diff --git a/lambdas/report-event-transformer/src/apis/firehose-handler.ts b/lambdas/report-event-transformer/src/apis/firehose-handler.ts index 171003a2..5921bf9a 100644 --- a/lambdas/report-event-transformer/src/apis/firehose-handler.ts +++ b/lambdas/report-event-transformer/src/apis/firehose-handler.ts @@ -56,7 +56,7 @@ function validateRecord( } function generateReportEvent(validatedRecord: ValidatedRecord): ReportEvent { - const { messageReference, pageCount, senderId, supplierId } = + const { messageReference, pageCount, senderId, status, supplierId } = validatedRecord.event.data; const { time, type } = validatedRecord.event; const eventTime = new Date(time); @@ -66,6 +66,7 @@ function generateReportEvent(validatedRecord: ValidatedRecord): ReportEvent { senderId, pageCount, supplierId, + letterStatus: status, time, type, }; diff --git a/lambdas/report-event-transformer/src/types/events.ts b/lambdas/report-event-transformer/src/types/events.ts index c018f0f2..5a75e68d 100644 --- a/lambdas/report-event-transformer/src/types/events.ts +++ b/lambdas/report-event-transformer/src/types/events.ts @@ -6,6 +6,7 @@ export const $DigitalLettersEvent = z.object({ senderId: z.string(), pageCount: z.number().optional(), supplierId: z.string().optional(), + status: z.string().optional(), }), time: z.string(), type: z.string(), @@ -18,6 +19,7 @@ export type FlatDigitalLettersEvent = { senderId: string; pageCount?: number; supplierId?: string; + letterStatus?: string; time: string; type: string; }; From af74a2a4890ecd1a62b3d8cbffca621a86edd01a Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 13:53:57 +0000 Subject: [PATCH 12/19] CCM-13304: Fix Athena Workgroup arn --- infrastructure/terraform/components/dl/locals.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index dea77baa..1b79045a 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -4,7 +4,7 @@ locals { apim_keystore_s3_bucket = "nhs-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-static-assets" apim_private_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/private_key" athena_reporting_database = "${local.csi}-reporting" - athena_workgroup_arn = "arn:aws:athena:${var.region}:${var.shared_infra_account_id}:workgroup/${aws_athena_workgroup.reporting.name}" + athena_workgroup_arn = "arn:aws:athena:${var.region}:${var.aws_account_id}:workgroup/${aws_athena_workgroup.reporting.name}" aws_lambda_functions_dir_path = "../../../../lambdas" deploy_pdm_mock = var.enable_pdm_mock firehose_output_path_prefix = "kinesis-firehose-output" From 9f0938f6bc47eddb4e6802153edcaa5152792eb6 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 14:04:38 +0000 Subject: [PATCH 13/19] CCM-13304: Update report bucket permission for Athena --- .../components/dl/module_lambda_report_generator.tf | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf index c5b96d38..b139e27c 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -52,13 +52,15 @@ data "aws_iam_policy_document" "report_generator_lambda" { effect = "Allow" actions = [ - "s3:GetObject", "s3:PutObject", - "s3:DeleteObject" + "s3:GetObject", + "s3:GetBucketLocation", + "s3:ListBucket" ] resources = [ - "${module.s3bucket_reporting.arn}/*" + "${module.s3bucket_reporting.arn}/*", + "${module.s3bucket_reporting.arn}" ] } From e6f00cb678c113a03c51285f74e825eb42f67b4e Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 14:06:40 +0000 Subject: [PATCH 14/19] CCM-13304: Add letter status to athena table --- .../components/dl/glue_catalog_table_event_record.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf index c5bccad3..b86d29c0 100644 --- a/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf +++ b/infrastructure/terraform/components/dl/glue_catalog_table_event_record.tf @@ -37,8 +37,8 @@ resource "aws_glue_catalog_table" "event_record" { type = "string" } columns { - name = "type" - type = "letterstatus" + name = "letterstatus" + type = "string" } } From 94ba7f75aedf89c5b39b2e7f3b942fb44e177aef Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 14:42:10 +0000 Subject: [PATCH 15/19] CCM-13304: Update query --- .../report-generator/src/queries/report.sql | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/lambdas/report-generator/src/queries/report.sql b/lambdas/report-generator/src/queries/report.sql index 9228b625..fa645671 100644 --- a/lambdas/report-generator/src/queries/report.sql +++ b/lambdas/report-generator/src/queries/report.sql @@ -1,18 +1,54 @@ WITH vars AS ( - SELECT - CAST(? AS DATE) AS dt, + SELECT CAST(? AS DATE) AS dt, ? AS senderid +), +"translated_events" AS ( + SELECT e.messagereference, + e.time, + CASE + WHEN e.type LIKE '%.item.dequeued.%' + OR e.type LIKE '%.item.removed.%' THEN 'Digital' + WHEN e.type LIKE '%.print.letter.transitioned.%' THEN 'Print' ELSE NULL + END as communicationtype, + CASE + WHEN e.type LIKE '%.item.dequeued.%' THEN 'Unread' + WHEN e.type LIKE '%.item.removed.%' THEN 'Read' + WHEN e.letterstatus = 'REJECTED' THEN 'Rejected' + WHEN e.letterstatus = 'FAILED' THEN 'Failed' + WHEN e.letterstatus = 'DISPATCHED' THEN 'Dispatched' + WHEN e.letterstatus = 'REJECTED' THEN 'Rejected' ELSE NULL + END as status + FROM event_record e + CROSS JOIN vars v + WHERE e.senderid = v.senderid + AND e.__year = year(v.dt) + AND e.__month = month(v.dt) + AND e.__day = day(v.dt) +), +"ordered_events" AS ( + SELECT ROW_NUMBER() OVER ( + PARTITION BY te.messagereference + ORDER BY te.time DESC, + CASE + -- Digital Priority Order + WHEN te.status = 'Read' THEN 2 + WHEN te.status = 'Unread' THEN 1 -- Print Priority Order + WHEN te.status = 'Returned' THEN 4 + WHEN te.status = 'Failed' THEN 3 + WHEN te.status = 'Dispatched' THEN 2 + WHEN te.status = 'Rejected' THEN 1 ELSE 0 + END DESC + ) AS "row_number", + te.messagereference, + te.time, + te.communicationtype, + te.status + FROM "translated_events" AS te + where te.status IS NOT NULL ) -SELECT - e.messagereference, - e.pagecount, - e.supplierid, - e.time, - e.type, - e.senderid -FROM event_record e -CROSS JOIN vars v -WHERE e.senderid = v.senderid - AND e.__year = year(v.dt) - AND e.__month = month(v.dt) - AND e.__day = day(v.dt); +SELECT oe.messagereference, + oe.time, + oe.communicationtype, + oe.status +FROM "ordered_events" AS oe +WHERE oe.row_number = 1 From 0277b564d2394bfdbef16f757d2b3364d44f939e Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 14:42:40 +0000 Subject: [PATCH 16/19] CCM-13304: Update query --- lambdas/report-generator/src/queries/report.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lambdas/report-generator/src/queries/report.sql b/lambdas/report-generator/src/queries/report.sql index fa645671..1a958581 100644 --- a/lambdas/report-generator/src/queries/report.sql +++ b/lambdas/report-generator/src/queries/report.sql @@ -32,7 +32,8 @@ WITH vars AS ( CASE -- Digital Priority Order WHEN te.status = 'Read' THEN 2 - WHEN te.status = 'Unread' THEN 1 -- Print Priority Order + WHEN te.status = 'Unread' THEN 1 + -- Print Priority Order WHEN te.status = 'Returned' THEN 4 WHEN te.status = 'Failed' THEN 3 WHEN te.status = 'Dispatched' THEN 2 From b8bb5bc092f900b1b18ddbac735e256a25d91e06 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 15:15:18 +0000 Subject: [PATCH 17/19] CCM-13304: Query fix attempt --- lambdas/report-generator/src/queries/report.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lambdas/report-generator/src/queries/report.sql b/lambdas/report-generator/src/queries/report.sql index 1a958581..e5b5a6c3 100644 --- a/lambdas/report-generator/src/queries/report.sql +++ b/lambdas/report-generator/src/queries/report.sql @@ -1,6 +1,6 @@ WITH vars AS ( - SELECT CAST(? AS DATE) AS dt, - ? AS senderid + SELECT DATE(CAST(? AS VARCHAR)) AS dt, + CAST(? AS VARCHAR) AS senderid ), "translated_events" AS ( SELECT e.messagereference, @@ -27,7 +27,7 @@ WITH vars AS ( ), "ordered_events" AS ( SELECT ROW_NUMBER() OVER ( - PARTITION BY te.messagereference + PARTITION BY te.messagereference, te.communicationtype ORDER BY te.time DESC, CASE -- Digital Priority Order From ee60d9e74421cd7663eed7a409201dd80f5313a9 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 15:20:42 +0000 Subject: [PATCH 18/19] CCM-13304: Query fix attempt --- .../src/__tests__/app/report-generator.test.ts | 6 +++--- lambdas/report-generator/src/app/report-generator.ts | 2 +- lambdas/report-generator/src/queries/report.sql | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lambdas/report-generator/src/__tests__/app/report-generator.test.ts b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts index b0c3ace4..95b497c2 100644 --- a/lambdas/report-generator/src/__tests__/app/report-generator.test.ts +++ b/lambdas/report-generator/src/__tests__/app/report-generator.test.ts @@ -73,7 +73,7 @@ describe('ReportGenerator', () => { ); expect(mockReportService.generateReport).toHaveBeenCalledWith( mockQuery, - ['2025-01-15', 'sender-123'], + ["'2025-01-15'", "'sender-123'"], 'transactional-reports/sender-123/completed_communications/completed_communications_2025-01-15.csv', ); expect(result).toEqual({ @@ -99,7 +99,7 @@ describe('ReportGenerator', () => { expect(mockReportService.generateReport).toHaveBeenCalledWith( expect.any(String), - ['2025-02-20', 'sender-456'], + ["'2025-02-20'", "'sender-456'"], 'transactional-reports/sender-456/completed_communications/completed_communications_2025-02-20.csv', ); }); @@ -113,7 +113,7 @@ describe('ReportGenerator', () => { expect(mockReportService.generateReport).toHaveBeenCalledWith( mockQuery, - ['2025-01-15', 'sender-123'], + ["'2025-01-15'", "'sender-123'"], expect.any(String), ); }); diff --git a/lambdas/report-generator/src/app/report-generator.ts b/lambdas/report-generator/src/app/report-generator.ts index 6bba6d9a..30837d58 100644 --- a/lambdas/report-generator/src/app/report-generator.ts +++ b/lambdas/report-generator/src/app/report-generator.ts @@ -29,7 +29,7 @@ export class ReportGenerator { const location = await this.reportService.generateReport( query, - [reportDate, senderId], + [`'${reportDate}'`, `'${senderId}'`], reportFilePath, ); diff --git a/lambdas/report-generator/src/queries/report.sql b/lambdas/report-generator/src/queries/report.sql index e5b5a6c3..3bdf0fd6 100644 --- a/lambdas/report-generator/src/queries/report.sql +++ b/lambdas/report-generator/src/queries/report.sql @@ -1,6 +1,6 @@ WITH vars AS ( - SELECT DATE(CAST(? AS VARCHAR)) AS dt, - CAST(? AS VARCHAR) AS senderid + SELECT CAST(? AS DATE) AS dt, + ? AS senderid ), "translated_events" AS ( SELECT e.messagereference, From 8a521d238d331b9fa5124036c8fcdbaea1dde1a0 Mon Sep 17 00:00:00 2001 From: simonlabarere Date: Wed, 11 Feb 2026 15:47:53 +0000 Subject: [PATCH 19/19] CCM-13304: update permissions to run athena query --- .../terraform/components/dl/locals.tf | 1 - .../dl/module_lambda_report_generator.tf | 20 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index 1b79045a..301f11f0 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -4,7 +4,6 @@ locals { apim_keystore_s3_bucket = "nhs-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-static-assets" apim_private_key_ssm_parameter_name = "/${var.component}/${var.environment}/apim/private_key" athena_reporting_database = "${local.csi}-reporting" - athena_workgroup_arn = "arn:aws:athena:${var.region}:${var.aws_account_id}:workgroup/${aws_athena_workgroup.reporting.name}" aws_lambda_functions_dir_path = "../../../../lambdas" deploy_pdm_mock = var.enable_pdm_mock firehose_output_path_prefix = "kinesis-firehose-output" diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf index b139e27c..fdc3f58a 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -89,7 +89,25 @@ data "aws_iam_policy_document" "report_generator_lambda" { ] resources = [ - local.athena_workgroup_arn + "arn:aws:athena:${var.region}:${var.aws_account_id}:workgroup/${aws_athena_workgroup.reporting.name}" + ] + } + + statement { + sid = "AllowGlueAccess" + effect = "Allow" + + actions = [ + "glue:GetTable", + "glue:GetDatabase", + "glue:GetPartition", + "glue:GetPartitions", + ] + + resources = [ + "arn:aws:glue:${var.region}:${var.aws_account_id}:catalog", + "arn:aws:glue:${var.region}:${var.aws_account_id}:database/${local.athena_reporting_database}", + "arn:aws:glue:${var.region}:${var.aws_account_id}:table/${local.athena_reporting_database}/*" ] }