Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4906c7d
CCM-13303: Update Generate Report schema to require a reportDate
simonlabarere Feb 4, 2026
e6d2b58
CCM-13304: Initial commit
simonlabarere Feb 10, 2026
a07bc6c
Merge branch 'main' into feature/CCM-13304_generate_reports
simonlabarere Feb 10, 2026
be5fef3
CCM-13304: Fix existing test
simonlabarere Feb 10, 2026
5c2c8e8
CCM-13304: Fix existing test
simonlabarere Feb 10, 2026
cb4822a
CCM-13304: Added a helper to trigger Glue table refresh
gareth-allan Feb 10, 2026
5b64e91
Merge branch 'main' into feature/CCM-13304_generate_reports
simonlabarere Feb 10, 2026
d753b4e
CCM-13304: Unit tests
simonlabarere Feb 10, 2026
b3cd51d
CCM-13304: Unit tests
simonlabarere Feb 10, 2026
83fc83e
CCM-13304: Fix terraform
simonlabarere Feb 10, 2026
531bd8d
CCM-13304: Added a component test for report generation
gareth-allan Feb 10, 2026
7820414
Merge branch 'main' into feature/CCM-13304_component_test_helpers
gareth-allan Feb 10, 2026
6ab35e2
CCM-13304: Bundle SQL in lambda
simonlabarere Feb 11, 2026
4c987cb
CCM-13304: Bundle SQL in lambda
simonlabarere Feb 11, 2026
0a9383d
CCM-13304: Add permission to run Athen queries
simonlabarere Feb 11, 2026
52dd47a
CCM-13304: Add letter status to athena table
simonlabarere Feb 11, 2026
af74a2a
CCM-13304: Fix Athena Workgroup arn
simonlabarere Feb 11, 2026
9f0938f
CCM-13304: Update report bucket permission for Athena
simonlabarere Feb 11, 2026
e6f00cb
CCM-13304: Add letter status to athena table
simonlabarere Feb 11, 2026
94ba7f7
CCM-13304: Update query
simonlabarere Feb 11, 2026
0277b56
CCM-13304: Update query
simonlabarere Feb 11, 2026
b8bb5bc
CCM-13304: Query fix attempt
simonlabarere Feb 11, 2026
ee60d9e
CCM-13304: Query fix attempt
simonlabarere Feb 11, 2026
8a521d2
CCM-13304: update permissions to run athena query
simonlabarere Feb 11, 2026
33a3a41
CCM-13304: Tweaks following review
gareth-allan Feb 13, 2026
09c9963
Merge branch 'main' into feature/CCM-13304_generate_reports
gareth-allan Feb 13, 2026
4ed6c84
CCM-13304: Linting/test tweaks
gareth-allan Feb 13, 2026
5eb32eb
Merge branch 'main' into feature/CCM-13304_component_test_helpers
gareth-allan Feb 13, 2026
2d1e07b
Merge branch 'feature/CCM-13304_component_test_helpers' into feature/…
gareth-allan Feb 13, 2026
f3af3ef
CCM-13304: First attempt at component test
gareth-allan Feb 13, 2026
bfdfd1a
CCM-13304: Remove test.only
gareth-allan Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ indent_size = unset
[*.py]
indent_size = 4

[*.ts]
quote_type = single

[{Dockerfile,Dockerfile.}*]
indent_size = 4

Expand Down
4 changes: 4 additions & 0 deletions infrastructure/terraform/components/dl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ No requirements.
| <a name="input_apim_auth_token_url"></a> [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 |
| <a name="input_apim_base_url"></a> [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to PDM | `string` | `"https://int.api.service.nhs.uk"` | no |
| <a name="input_apim_keygen_schedule"></a> [apim\_keygen\_schedule](#input\_apim\_keygen\_schedule) | Schedule to refresh key pairs if necessary | `string` | `"cron(0 14 * * ? *)"` | no |
| <a name="input_athena_query_max_polling_attempts"></a> [athena\_query\_max\_polling\_attempts](#input\_athena\_query\_max\_polling\_attempts) | The number of times athena will be polled to check if a query is completed | `number` | `50` | no |
| <a name="input_athena_query_polling_time_seconds"></a> [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 |
| <a name="input_aws_account_id"></a> [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
| <a name="input_aws_account_type"></a> [aws\_account\_type](#input\_aws\_account\_type) | The AWS Account Type | `string` | n/a | yes |
| <a name="input_component"></a> [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no |
Expand Down Expand Up @@ -65,6 +67,7 @@ No requirements.
| <a name="module_print_sender"></a> [print\_sender](#module\_print\_sender) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| <a name="module_print_status_handler"></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 |
| <a name="module_report_event_transformer"></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 |
| <a name="module_report_generator"></a> [report\_generator](#module\_report\_generator) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-lambda.zip | n/a |
| <a name="module_report_scheduler"></a> [report\_scheduler](#module\_report\_scheduler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| <a name="module_s3bucket_cf_logs"></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 |
| <a name="module_s3bucket_file_quarantine"></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 |
Expand All @@ -84,6 +87,7 @@ No requirements.
| <a name="module_sqs_print_analyser"></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 |
| <a name="module_sqs_print_sender"></a> [sqs\_print\_sender](#module\_sqs\_print\_sender) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
| <a name="module_sqs_print_status_handler"></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 |
| <a name="module_sqs_report_generator"></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 |
| <a name="module_sqs_scanner"></a> [sqs\_scanner](#module\_sqs\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
| <a name="module_sqs_ttl"></a> [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
| <a name="module_sqs_ttl_handle_expiry_errors"></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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ resource "aws_glue_catalog_table" "event_record" {
name = "type"
type = "string"
}
columns {
name = "letterstatus"
type = "string"
}
}

partition_keys {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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 generate 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" = aws_glue_catalog_database.reporting.name
"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_attempts
"REPORTING_BUCKET" = module.s3bucket_reporting.bucket
"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:PutObject",
"s3:GetObject",
"s3:GetBucketLocation",
"s3:ListBucket"
]

resources = [
"${module.s3bucket_reporting.arn}/*",
"${module.s3bucket_reporting.arn}"
]
}

statement {
sid = "KMSPermissions"
effect = "Allow"

actions = [
"kms:Decrypt",
"kms:GenerateDataKey",
]

resources = [
module.kms.key_arn,
]
}

statement {
sid = "AllowAthenaAccess"
effect = "Allow"

actions = [
"athena:StartQueryExecution",
"athena:GetQueryResults",
"athena:GetQueryExecution"
]

resources = [
"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/${aws_glue_catalog_database.reporting.name}",
"arn:aws:glue:${var.region}:${var.aws_account_id}:table/${aws_glue_catalog_database.reporting.name}/*"
]
}

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,
]
}
}
Original file line number Diff line number Diff line change
@@ -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]
}
}
}
12 changes: 12 additions & 0 deletions infrastructure/terraform/components/dl/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,15 @@ variable "metadata_refresh_schedule" {
description = "Schedule for refreshing reporting metadata."
default = "cron(10 6-22 * * ? *)" # 10 minutes past the hour, between 06:00 - 22:00
}

variable "athena_query_max_polling_attempts" {
type = number
description = "The number of times athena will be polled to check if a query is completed"
default = 50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 50 attempts not a bit high?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied this from core.

}

variable "athena_query_polling_time_seconds" {
type = number
description = "The amount of time in seconds to wait between each athena poll"
default = 15
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('Firehose Handler', () => {
reasonText: digitalLettersEvent.data.reasonText,
senderId: digitalLettersEvent.data.senderId,
supplierId: digitalLettersEvent.data.supplierId,
letterStatus: digitalLettersEvent.data.status,
time: digitalLettersEvent.time,
type: digitalLettersEvent.type,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const digitalLettersEvent = {
reasonCode: 'FAILURE001',
reasonText: 'Letter has too many pages',
senderId: 'sender1',
status: 'DISPATCHED',
supplierId: 'supplier1',
},
} as DigitalLettersEvent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function generateReportEvent(validatedRecord: ValidatedRecord): ReportEvent {
reasonCode,
reasonText,
senderId,
status,
supplierId,
} = validatedRecord.event.data;
const { time, type } = validatedRecord.event;
Expand All @@ -72,6 +73,7 @@ function generateReportEvent(validatedRecord: ValidatedRecord): ReportEvent {
pageCount,
senderId,
supplierId,
letterStatus: status,
reasonCode,
reasonText,
time,
Expand Down
2 changes: 2 additions & 0 deletions lambdas/report-event-transformer/src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const $DigitalLettersEvent = z.object({
reasonText: z.string().optional(),
senderId: z.string(),
supplierId: z.string().optional(),
status: z.string().optional(),
}),
time: z.string(),
type: z.string(),
Expand All @@ -20,6 +21,7 @@ export type FlatDigitalLettersEvent = {
pageCount?: number;
senderId: string;
supplierId?: string;
letterStatus?: string;
reasonCode?: string;
reasonText?: string;
time: string;
Expand Down
14 changes: 14 additions & 0 deletions lambdas/report-generator/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { baseJestConfig } from '../../jest.config.base';

const config = baseJestConfig;

config.coverageThreshold = {
global: {
branches: 88,
functions: 100,
lines: 97,
statements: 97,
},
};

export default config;
24 changes: 24 additions & 0 deletions lambdas/report-generator/package.json
Original file line number Diff line number Diff line change
@@ -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 && cp -r src/queries dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:unit": "jest",
"typecheck": "tsc --noEmit"
},
"version": "0.0.1"
}
Loading
Loading