diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml
index b6d5086e9..b01f0ba1e 100644
--- a/.github/workflows/cicd-1-pull-request.yaml
+++ b/.github/workflows/cicd-1-pull-request.yaml
@@ -8,8 +8,6 @@ on:
- main
pull_request:
types: [opened, reopened, synchronize]
- branches:
- - main
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml
index 22a3c28b3..19e9a622a 100644
--- a/.github/workflows/stage-2-test.yaml
+++ b/.github/workflows/stage-2-test.yaml
@@ -64,6 +64,14 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
+ # Pull testcontainers images in the background so the download overlaps
+ # the setup steps. Keep these references in sync with the images used in
+ # internal/datastore/src/__test__/db.ts and
+ # internal/sql-test-helpers/src/trino.ts.
+ - name: "Pre-pull test container images"
+ run: |
+ docker pull amazon/dynamodb-local > /dev/null 2>&1 &
+ docker pull trinodb/trino:476 > /dev/null 2>&1 &
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Repo setup"
diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index f0d69354e..354fc99c9 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -92,6 +92,7 @@ No requirements.
| [populate\_supplier\_config\_cache](#module\_populate\_supplier\_config\_cache) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip | n/a |
+| [s3bucket\_athena\_query\_results](#module\_s3bucket\_athena\_query\_results) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a |
| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.6/terraform-s3bucket.zip | n/a |
| [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
| [sqs\_alarms](#module\_sqs\_alarms) | ../../modules/alarms-sqs | n/a |
diff --git a/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf
new file mode 100644
index 000000000..e3939d786
--- /dev/null
+++ b/infrastructure/terraform/components/api/athena_workgroup_letter_sla_breaches.tf
@@ -0,0 +1,19 @@
+resource "aws_athena_workgroup" "letter_sla_breaches" {
+ name = "${local.csi}-letter-sla-breaches"
+ description = "Athena workgroup for the letter SLA breaches state machine"
+ force_destroy = var.force_destroy
+
+ configuration {
+ enforce_workgroup_configuration = true
+ publish_cloudwatch_metrics_enabled = true
+
+ result_configuration {
+ output_location = "s3://${module.s3bucket_athena_query_results.bucket}/letter-sla-breaches/"
+
+ encryption_configuration {
+ encryption_option = "SSE_KMS"
+ kms_key_arn = module.kms.key_arn
+ }
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
index 25d483c6c..501d1aa05 100644
--- a/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
+++ b/infrastructure/terraform/components/api/iam_role_policy_letter_sla_breaches.tf
@@ -1,5 +1,6 @@
resource "aws_iam_role_policy" "letter_sla_breaches" {
- count = local.eventpub_parquet_enabled ? 1 : 0
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
name = "${local.csi}-letter-sla-breaches-sfn"
role = aws_iam_role.letter_sla_breaches[0].id
policy = data.aws_iam_policy_document.letter_sla_breaches_policy[0].json
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 1dbc78c51..8f8f9bf57 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -78,6 +78,10 @@ locals {
event_cache_bucket_name = lookup(module.eventpub.s3_bucket_event_cache, "bucket", null)
eventsub_event_cache_bucket_name = lookup(module.eventsub.s3_bucket_event_cache, "bucket", null)
+ reporting_bucket_name = lookup(module.s3bucket_reporting, "bucket", null)
+
+ # The Glue crawler names tables after the crawled bucket, replacing characters that are invalid in table names
+ event_cache_glue_table_name = local.event_cache_bucket_name != null ? lower(replace(local.event_cache_bucket_name, "/[^a-zA-Z0-9_]/", "_")) : null
csoc_api_gw_log_destination_arn = format("arn:aws:logs:%s:%s:destination:api_gateway_log_destination",
var.region,
diff --git a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
index 083f5ddfe..d951c02c3 100644
--- a/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
+++ b/infrastructure/terraform/components/api/module_lambda_populate_standard_breaches.tf
@@ -1,5 +1,6 @@
module "populate_standard_breaches" {
- count = local.eventpub_parquet_enabled ? 1 : 0
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/6.0.5/terraform-lambda.zip"
function_name = "populate_standard_breaches"
@@ -26,7 +27,7 @@ module "populate_standard_breaches" {
handler_function_name = "populateStandardBreachesHandler"
runtime = "nodejs22.x"
memory = 128
- timeout = 29
+ timeout = 300
log_level = var.log_level
force_lambda_code_deploy = var.force_lambda_code_deploy
@@ -37,9 +38,12 @@ module "populate_standard_breaches" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = {
- ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name
- ATHENA_DATABASE = aws_glue_catalog_database.supplier.name
- EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name
+ ATHENA_WORKGROUP = aws_athena_workgroup.reporting.name
+ ATHENA_DATABASE = aws_glue_catalog_database.supplier.name
+ EVENT_CACHE_TABLE_NAME = aws_glue_catalog_table.eventpub_parquet[0].name
+ PACK_SPECIFICATION_TABLE_NAME = "${replace(local.csi, "-", "_")}_pack_specification"
+ DAYS_TO_LOOK_BACK = "14"
+ STANDARD_SLA_WORKING_DAYS = "2"
}
}
diff --git a/infrastructure/terraform/components/api/s3_athena_query_results.tf b/infrastructure/terraform/components/api/s3_athena_query_results.tf
new file mode 100644
index 000000000..68d514b78
--- /dev/null
+++ b/infrastructure/terraform/components/api/s3_athena_query_results.tf
@@ -0,0 +1,73 @@
+module "s3bucket_athena_query_results" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip"
+
+ name = "athena-query-results"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = var.component
+
+ acl = "private"
+ force_destroy = var.force_destroy
+ versioning = false
+ kms_key_arn = module.kms.key_arn
+
+ lifecycle_rules = [
+ {
+ enabled = true
+
+ expiration = {
+ days = "7"
+ }
+
+ abort_incomplete_multipart_upload = {
+ days = "1"
+ }
+ }
+ ]
+
+ policy_documents = [
+ data.aws_iam_policy_document.s3bucket_athena_query_results.json
+ ]
+
+ bucket_logging_target = {
+ bucket = local.acct.s3_buckets["access_logs"]["id"]
+ }
+
+ public_access = {
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+ }
+
+ default_tags = {
+ Name = "Athena Query Results"
+ }
+}
+
+data "aws_iam_policy_document" "s3bucket_athena_query_results" {
+ statement {
+ sid = "DontAllowNonSecureConnection"
+ effect = "Deny"
+ actions = ["s3:*"]
+
+ resources = [
+ module.s3bucket_athena_query_results.arn,
+ "${module.s3bucket_athena_query_results.arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = ["*"]
+ }
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
index ccb5a8ef9..5f3187fc7 100644
--- a/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
+++ b/infrastructure/terraform/components/api/sfn_state_machine_letter_sla_breaches.tf
@@ -1,5 +1,6 @@
resource "aws_sfn_state_machine" "letter_sla_breaches" {
- count = local.eventpub_parquet_enabled ? 1 : 0
+ count = local.eventpub_parquet_enabled ? 1 : 0
+
name = local.letter_sla_breaches_state_machine_name
role_arn = aws_iam_role.letter_sla_breaches[0].arn
diff --git a/infrastructure/terraform/modules/alarms-sla-breaches/README.md b/infrastructure/terraform/modules/alarms-sla-breaches/README.md
new file mode 100644
index 000000000..df8c1f5c0
--- /dev/null
+++ b/infrastructure/terraform/modules/alarms-sla-breaches/README.md
@@ -0,0 +1,19 @@
+
+
+
+
+## Requirements
+
+No requirements.
+## Inputs
+
+No inputs.
+## Modules
+
+No modules.
+## Outputs
+
+No outputs.
+
+
+
diff --git a/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf b/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf
new file mode 100644
index 000000000..662715893
--- /dev/null
+++ b/infrastructure/terraform/modules/alarms-sla-breaches/cloudwatch_metric_alarm_errors.tf
@@ -0,0 +1,22 @@
+resource "aws_cloudwatch_metric_alarm" "errors" {
+ alarm_name = "${var.alarm_prefix}-lambda-${var.function_name}-errors"
+ alarm_description = "ERROR: Lambda errors"
+
+ namespace = "AWS/Lambda"
+ metric_name = "Errors"
+ statistic = "Sum"
+ period = var.period_seconds
+
+ evaluation_periods = var.evaluation_periods
+ threshold = var.errors_threshold
+ comparison_operator = "GreaterThanThreshold"
+ treat_missing_data = "notBreaching"
+
+ dimensions = { FunctionName = var.function_name }
+
+ actions_enabled = false
+ alarm_actions = []
+ ok_actions = []
+ insufficient_data_actions = []
+ tags = var.tags
+}
diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts
index 7e5f18edf..76c245175 100644
--- a/internal/datastore/src/__test__/db.ts
+++ b/internal/datastore/src/__test__/db.ts
@@ -9,6 +9,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DatastoreConfig } from "../config";
export async function setupDynamoDBContainer() {
+ // Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync.
const container = await new GenericContainer("amazon/dynamodb-local")
.withExposedPorts(8000)
.start();
diff --git a/internal/helpers/src/__tests__/metrics.test.ts b/internal/helpers/src/__tests__/metrics.test.ts
new file mode 100644
index 000000000..ce458e0ba
--- /dev/null
+++ b/internal/helpers/src/__tests__/metrics.test.ts
@@ -0,0 +1,151 @@
+import type { MetricsLogger } from "aws-embedded-metrics";
+import { Unit } from "aws-embedded-metrics";
+import {
+ MetricStatus,
+ buildEMFObject,
+ emitForSingleSupplier,
+} from "../metrics";
+
+describe("emitForSingleSupplier", () => {
+ const originalFunctionName = process.env.AWS_LAMBDA_FUNCTION_NAME;
+
+ afterEach(() => {
+ if (originalFunctionName === undefined) {
+ delete process.env.AWS_LAMBDA_FUNCTION_NAME;
+ } else {
+ process.env.AWS_LAMBDA_FUNCTION_NAME = originalFunctionName;
+ }
+ });
+
+ it("sets the function namespace, supplier dimensions, and count metric", () => {
+ delete process.env.AWS_LAMBDA_FUNCTION_NAME;
+ const metrics = {
+ setNamespace: jest.fn(),
+ putDimensions: jest.fn(),
+ putMetric: jest.fn(),
+ } as unknown as MetricsLogger;
+
+ emitForSingleSupplier(
+ metrics,
+ "process-letter",
+ "supplier-1",
+ 3,
+ MetricStatus.Success,
+ );
+
+ expect(metrics.setNamespace).toHaveBeenCalledWith("process-letter");
+ expect(metrics.putDimensions).toHaveBeenCalledWith({
+ Supplier: "supplier-1",
+ });
+ expect(metrics.putMetric).toHaveBeenCalledWith(
+ MetricStatus.Success,
+ 3,
+ Unit.Count,
+ );
+ });
+
+ it("uses the Lambda function name and preserves additional dimensions", () => {
+ process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function";
+ const metrics = {
+ setNamespace: jest.fn(),
+ putDimensions: jest.fn(),
+ putMetric: jest.fn(),
+ } as unknown as MetricsLogger;
+
+ emitForSingleSupplier(
+ metrics,
+ "fallback-function",
+ "supplier-1",
+ 2,
+ MetricStatus.Failure,
+ { Environment: "test", Supplier: "overridden-supplier" },
+ );
+
+ expect(metrics.setNamespace).toHaveBeenCalledWith("lambda-function");
+ expect(metrics.putDimensions).toHaveBeenCalledWith({
+ Environment: "test",
+ Supplier: "supplier-1",
+ });
+ expect(metrics.putMetric).toHaveBeenCalledWith(
+ MetricStatus.Failure,
+ 2,
+ Unit.Count,
+ );
+ });
+});
+
+describe("buildEMFObject", () => {
+ const originalFunctionName = process.env.AWS_LAMBDA_FUNCTION_NAME;
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ if (originalFunctionName === undefined) {
+ delete process.env.AWS_LAMBDA_FUNCTION_NAME;
+ } else {
+ process.env.AWS_LAMBDA_FUNCTION_NAME = originalFunctionName;
+ }
+ });
+
+ it("builds an EMF object using the supplied function name", () => {
+ delete process.env.AWS_LAMBDA_FUNCTION_NAME;
+ jest.spyOn(Date, "now").mockReturnValue(123_456_789);
+
+ expect(
+ buildEMFObject(
+ "process-letter",
+ { Supplier: "supplier-1", Environment: "test" },
+ { key: MetricStatus.Success, value: 1, unit: Unit.Count },
+ ),
+ ).toEqual({
+ Supplier: "supplier-1",
+ Environment: "test",
+ _aws: {
+ Timestamp: 123_456_789,
+ CloudWatchMetrics: [
+ {
+ Namespace: "process-letter",
+ Dimensions: [["Supplier", "Environment"]],
+ Metrics: [
+ {
+ Name: MetricStatus.Success,
+ Value: 1,
+ Unit: Unit.Count,
+ },
+ ],
+ },
+ ],
+ },
+ [MetricStatus.Success]: 1,
+ });
+ });
+
+ it("prefers the Lambda function name for the EMF namespace", () => {
+ process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function";
+
+ const emfObject = buildEMFObject(
+ "fallback-function",
+ { Supplier: "supplier-1" },
+ { key: "BreachCount", value: 4, unit: Unit.Count },
+ );
+
+ expect(emfObject._aws.CloudWatchMetrics[0].Namespace).toBe(
+ "lambda-function",
+ );
+ });
+
+ it("uses the optional dimension sets if provided", () => {
+ process.env.AWS_LAMBDA_FUNCTION_NAME = "lambda-function";
+
+ const emfObject = buildEMFObject(
+ "process-letter",
+ { Supplier: "supplier-1" },
+ { key: "BreachCount", value: 4, unit: Unit.Count },
+ [[], ["Supplier"]],
+ );
+
+ expect(emfObject._aws.CloudWatchMetrics[0].Dimensions).toEqual([
+ [],
+ ["Supplier"],
+ ]);
+ });
+});
diff --git a/internal/helpers/src/metrics.ts b/internal/helpers/src/metrics.ts
index 74ed3622a..df79b62e0 100644
--- a/internal/helpers/src/metrics.ts
+++ b/internal/helpers/src/metrics.ts
@@ -32,6 +32,7 @@ export function buildEMFObject(
functionName: string,
dimensions: Record,
metric: MetricEntry,
+ dimensionSets?: string[][],
) {
const namespace = process.env.AWS_LAMBDA_FUNCTION_NAME || functionName;
return {
@@ -41,7 +42,7 @@ export function buildEMFObject(
CloudWatchMetrics: [
{
Namespace: namespace,
- Dimensions: [Object.keys(dimensions)],
+ Dimensions: dimensionSets || [Object.keys(dimensions)],
Metrics: [
{ Name: metric.key, Value: metric.value, Unit: metric.unit },
],
diff --git a/internal/sql-test-helpers/README.md b/internal/sql-test-helpers/README.md
new file mode 100644
index 000000000..d86694a32
--- /dev/null
+++ b/internal/sql-test-helpers/README.md
@@ -0,0 +1,46 @@
+
+
+# @internal/sql-test-helpers
+
+## Purpose
+
+Test-only helpers for running SQL queries against databases in [testcontainers](https://node.testcontainers.org/), so that query builders can be checked against a real SQL engine using known test data.
+
+## General Structure highlights
+
+- **`src/trino.ts`**: Trino helpers, which talk to Trino over its REST API using `fetch`:
+ - `setupTrinoContainer()` — starts a `trinodb/trino` `GenericContainer` and returns a `TrinoContext` (`container`, `endpoint`).
+ - `runTrinoStatement(context, sql)` — runs a statement against the built-in `memory` catalog, follows result pagination and returns rows as `Record` (NULL → `undefined`).
+ - `runParameterisedQueryOnTrino(context, query)` — runs an Athena-style `{ queryString, executionParameters }` query via `EXECUTE IMMEDIATE ... USING`. Execution parameters are SQL literal expressions, as in Athena (for example `"'2026-10-14T12:00:00.000Z'"`).
+
+## Usage
+
+Add the package as a **devDependency** of the consuming workspace and import it from test code only:
+
+```json
+"devDependencies": {
+ "@internal/sql-test-helpers": "workspace:*"
+}
+```
+
+```ts
+import {
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "@internal/sql-test-helpers";
+
+const context = await setupTrinoContainer();
+await runTrinoStatement(context, "CREATE TABLE event_cache (...)");
+const rows = await runParameterisedQueryOnTrino(context, buildQuery(...));
+await context.container.stop();
+```
+
+## Nuances and Peculiarities
+
+- Must never be imported by production code. Because consumers list it under `devDependencies`, ESLint's `import-x/no-extraneous-dependencies` rule reports any import from non-test files.
+- Has no unit tests of its own and is excluded from coverage (see `sonar.coverage.exclusions` in `scripts/config/sonar-scanner.properties`). It is checked through the tests that use it.
+- Tests that use it require Docker. The first run pulls the Trino image (about 1 GB), so allow a generous `beforeAll` timeout (for example `180_000` ms). In CI, the image is pulled in the background at the start of the unit test job in `.github/workflows/stage-2-test.yaml`. If you change `TRINO_IMAGE`, update that step too.
+- Trino SQL is close to, but not identical to, Athena's engine version. The helpers check query logic, not every Athena-specific behaviour.
+
+
diff --git a/internal/sql-test-helpers/package.json b/internal/sql-test-helpers/package.json
new file mode 100644
index 000000000..af9e7aa3f
--- /dev/null
+++ b/internal/sql-test-helpers/package.json
@@ -0,0 +1,17 @@
+{
+ "dependencies": {
+ "testcontainers": "catalog:test"
+ },
+ "description": "Test-only helpers for running SQL queries against containerised databases",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "name": "@internal/sql-test-helpers",
+ "private": true,
+ "scripts": {
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "echo \"Test helpers package has no unit tests\"",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "0.1.0"
+}
diff --git a/internal/sql-test-helpers/src/index.ts b/internal/sql-test-helpers/src/index.ts
new file mode 100644
index 000000000..79ebbf71c
--- /dev/null
+++ b/internal/sql-test-helpers/src/index.ts
@@ -0,0 +1,8 @@
+export {
+ type SqlQuery,
+ type SqlRow,
+ type TrinoContext,
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "./trino";
diff --git a/internal/sql-test-helpers/src/trino.ts b/internal/sql-test-helpers/src/trino.ts
new file mode 100644
index 000000000..b86d17511
--- /dev/null
+++ b/internal/sql-test-helpers/src/trino.ts
@@ -0,0 +1,156 @@
+import { GenericContainer, StartedTestContainer, Wait } from "testcontainers";
+
+// Pre-pulled in CI by .github/workflows/stage-2-test.yaml; keep in sync.
+const TRINO_IMAGE = "trinodb/trino:476";
+const TRINO_PORT = 8080;
+
+// Structurally compatible with Athena's query/row shapes, so Athena query
+// builders can be exercised against Trino without depending on them here.
+export type SqlQuery = {
+ queryString: string;
+ executionParameters?: string[];
+};
+
+export type SqlRow = Record;
+
+type TrinoResponse = {
+ columns?: { name: string }[];
+ data?: unknown[][];
+ nextUri?: string;
+ error?: { message: string };
+};
+
+export type TrinoContext = {
+ container: StartedTestContainer;
+ endpoint: string;
+};
+
+const NODE_READY_TIMEOUT_MS = 60_000;
+const NODE_READY_POLL_MS = 500;
+
+const sleep = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+// Trino can report "No nodes available to run query" when the coordinator has
+// not (or no longer) registered a worker, e.g. just after "SERVER STARTED" or
+// on a loaded CI runner. It is raised before execution starts, so retrying is
+// safe even for non-idempotent statements.
+const isNoNodesError = (error: unknown) =>
+ error instanceof Error && error.message.includes("No nodes available");
+
+export async function setupTrinoContainer(): Promise {
+ const container = await new GenericContainer(TRINO_IMAGE)
+ .withExposedPorts(TRINO_PORT)
+ .withWaitStrategy(Wait.forLogMessage("======== SERVER STARTED ========"))
+ .withStartupTimeout(120_000)
+ .start();
+
+ const endpoint = `http://${container.getHost()}:${container.getMappedPort(TRINO_PORT)}`;
+ const context = { container, endpoint };
+
+ await runTrinoStatement(context, "SELECT 1");
+
+ return context;
+}
+
+async function readTrinoResponse(response: Response): Promise {
+ if (!response.ok) {
+ throw new Error(
+ `Trino request failed: ${response.status} ${await response.text()}`,
+ );
+ }
+
+ const body = (await response.json()) as TrinoResponse;
+
+ if (body.error) {
+ throw new Error(`Trino query failed: ${body.error.message}`);
+ }
+
+ return body;
+}
+
+function toRow(columns: string[], values: unknown[]): SqlRow {
+ return Object.fromEntries(
+ columns.map((name, i) => {
+ const value = values.at(i);
+ return [name, value === null ? undefined : String(value)];
+ }),
+ );
+}
+
+// Runs a statement via the Trino REST API against the in-memory catalog.
+export async function runTrinoStatement(
+ context: TrinoContext,
+ sql: string,
+): Promise {
+ const deadline = Date.now() + NODE_READY_TIMEOUT_MS;
+
+ for (;;) {
+ try {
+ return await executeTrinoStatement(context, sql);
+ } catch (error) {
+ if (!isNoNodesError(error) || Date.now() >= deadline) {
+ throw error;
+ }
+ }
+
+ await sleep(NODE_READY_POLL_MS);
+ }
+}
+
+async function executeTrinoStatement(
+ context: TrinoContext,
+ sql: string,
+): Promise {
+ let body = await readTrinoResponse(
+ await fetch(`${context.endpoint}/v1/statement`, {
+ method: "POST",
+ headers: {
+ "X-Trino-User": "test",
+ "X-Trino-Catalog": "memory",
+ "X-Trino-Schema": "default",
+ },
+ body: sql,
+ }),
+ );
+
+ let columns: string[] = [];
+ const rows: SqlRow[] = [];
+
+ while (true) {
+ if (body.columns) {
+ columns = body.columns.map((column) => column.name);
+ }
+
+ const currentColumns = columns;
+ rows.push(
+ ...(body.data ?? []).map((values) => toRow(currentColumns, values)),
+ );
+
+ if (!body.nextUri) {
+ return rows;
+ }
+
+ body = await readTrinoResponse(
+ await fetch(body.nextUri, { headers: { "X-Trino-User": "test" } }),
+ );
+ }
+}
+
+// Emulates Athena parameterised queries: each execution parameter is
+// substituted into the query as a SQL literal expression.
+export async function runParameterisedQueryOnTrino(
+ context: TrinoContext,
+ query: SqlQuery,
+): Promise {
+ const escapedQuery = query.queryString.replaceAll("'", "''");
+ const parameters = query.executionParameters ?? [];
+ const using = parameters.length > 0 ? ` USING ${parameters.join(", ")}` : "";
+
+ return runTrinoStatement(
+ context,
+ `EXECUTE IMMEDIATE '${escapedQuery}'${using}`,
+ );
+}
diff --git a/internal/sql-test-helpers/tsconfig.json b/internal/sql-test-helpers/tsconfig.json
new file mode 100644
index 000000000..70ae3804b
--- /dev/null
+++ b/internal/sql-test-helpers/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {},
+ "extends": "../../tsconfig.base.json",
+ "include": [
+ "src/**/*"
+ ]
+}
diff --git a/lambdas/letter-sla-breaches/common/package.json b/lambdas/letter-sla-breaches/common/package.json
index 7e3ef13d3..1160a6a43 100644
--- a/lambdas/letter-sla-breaches/common/package.json
+++ b/lambdas/letter-sla-breaches/common/package.json
@@ -1,8 +1,9 @@
{
"dependencies": {
- "@aws-sdk/client-athena": "^3.1149.0",
+ "@aws-sdk/client-athena": "catalog:aws",
"@internal/helpers": "workspace:*",
- "pino": "^10.4.0"
+ "pino": "catalog:app",
+ "zod": "catalog:app"
},
"description": "Common utilities for letter SLA breach lambdas",
"license": "MIT",
diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
index 485e6ab81..d33a405a0 100644
--- a/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
+++ b/lambdas/letter-sla-breaches/common/src/__tests__/athena.test.ts
@@ -9,29 +9,27 @@ import { type Deps } from "../deps";
const query = { queryString: "SELECT ?", executionParameters: ["'a'"] };
-function makeDeps(responses: unknown[]) {
- const send = jest.fn();
- for (const response of responses) {
- send.mockResolvedValueOnce(response);
- }
- const deps = {
- athenaClient: { send } as unknown as AthenaClient,
- logger: { info: jest.fn(), debug: jest.fn() },
- env: {
- ATHENA_WORKGROUP: "workgroup",
- ATHENA_DATABASE: "database",
- EVENT_CACHE_TABLE_NAME: "event_cache",
- PACK_SPECIFICATION_TABLE_NAME: "pack_specification",
- },
- } as unknown as Deps;
- return { deps, send };
-}
+const deps = {
+ athenaClient: { send: jest.fn() } as unknown as AthenaClient,
+ logger: { info: jest.fn(), debug: jest.fn() },
+ env: {
+ ATHENA_WORKGROUP: "workgroup",
+ ATHENA_DATABASE: "database",
+ EVENT_CACHE_TABLE_NAME: "event_cache",
+ },
+} as unknown as Deps;
const columnInfo = { ColumnInfo: [{ Name: "id" }, { Name: "status" }] };
+function mockResolvedValues(fn: any, returnValues: any[]) {
+ for (const value of returnValues) {
+ (fn as jest.Mock).mockResolvedValueOnce(value);
+ }
+}
+
describe("runAthenaQuery", () => {
it("starts the query, polls until complete and returns paginated rows", async () => {
- const { deps, send } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{ QueryExecution: { Status: { State: "QUEUED" } } },
{ QueryExecution: { Status: { State: "RUNNING" } } },
@@ -60,7 +58,9 @@ describe("runAthenaQuery", () => {
{ id: "1", status: "PENDING" },
{ id: "2", status: undefined },
]);
- const commands = send.mock.calls.map(([command]) => command);
+ const commands = (deps.athenaClient.send as jest.Mock).mock.calls.map(
+ ([command]) => command,
+ );
expect(commands[0]).toBeInstanceOf(StartQueryExecutionCommand);
expect(commands[0].input).toEqual({
QueryString: "SELECT ?",
@@ -77,7 +77,7 @@ describe("runAthenaQuery", () => {
});
it("handles empty result sets and missing metadata", async () => {
- const { deps } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{ QueryExecution: { Status: { State: "SUCCEEDED" } } },
{
@@ -91,7 +91,7 @@ describe("runAthenaQuery", () => {
});
it("handles a response with no result set", async () => {
- const { deps } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{ QueryExecution: { Status: { State: "SUCCEEDED" } } },
{},
@@ -102,7 +102,7 @@ describe("runAthenaQuery", () => {
it("uses the default poll interval when none is given", async () => {
jest.useFakeTimers();
- const { deps } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{},
{ QueryExecution: { Status: { State: "SUCCEEDED" } } },
@@ -117,7 +117,7 @@ describe("runAthenaQuery", () => {
});
it("throws when no query execution id is returned", async () => {
- const { deps } = makeDeps([{}]);
+ mockResolvedValues(deps.athenaClient.send, [{}]);
await expect(runAthenaQuery(deps, query)).rejects.toThrow(
"Athena did not return a query execution id",
@@ -127,7 +127,7 @@ describe("runAthenaQuery", () => {
it.each(["FAILED", "CANCELLED"])(
"throws when the query is %s",
async (state) => {
- const { deps } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{
QueryExecution: {
@@ -143,7 +143,7 @@ describe("runAthenaQuery", () => {
);
it("throws with a default reason when none is given", async () => {
- const { deps } = makeDeps([
+ mockResolvedValues(deps.athenaClient.send, [
{ QueryExecutionId: "qid" },
{ QueryExecution: { Status: { State: "FAILED" } } },
]);
diff --git a/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts b/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts
new file mode 100644
index 000000000..290f279e0
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/__tests__/deps.test.ts
@@ -0,0 +1,69 @@
+import { ZodError } from "zod";
+import createDependenciesContainer from "../deps";
+
+describe("createDependenciesContainer", () => {
+ const oldEnv = process.env;
+
+ beforeEach(() => {
+ process.env = {
+ ...oldEnv,
+ ATHENA_WORKGROUP: "workgroup",
+ ATHENA_DATABASE: "database",
+ EVENT_CACHE_TABLE_NAME: "event_cache",
+ PACK_SPECIFICATION_TABLE_NAME: "pack_specification",
+ DAYS_TO_LOOK_BACK: "14",
+ STANDARD_SLA_WORKING_DAYS: "2",
+ };
+ });
+
+ afterEach(() => {
+ process.env = oldEnv;
+ });
+
+ it("parses numeric configuration from the environment", () => {
+ const { env } = createDependenciesContainer();
+
+ expect(env.DAYS_TO_LOOK_BACK).toBe(14);
+ expect(env.STANDARD_SLA_WORKING_DAYS).toBe(2);
+ });
+
+ it("loads the full environment", () => {
+ const { env } = createDependenciesContainer();
+
+ expect(env).toEqual({
+ ATHENA_WORKGROUP: "workgroup",
+ ATHENA_DATABASE: "database",
+ EVENT_CACHE_TABLE_NAME: "event_cache",
+ PACK_SPECIFICATION_TABLE_NAME: "pack_specification",
+ DAYS_TO_LOOK_BACK: 14,
+ STANDARD_SLA_WORKING_DAYS: 2,
+ });
+ });
+
+ it("passes the optional log level through", () => {
+ process.env.PINO_LOG_LEVEL = "debug";
+
+ expect(createDependenciesContainer().env.PINO_LOG_LEVEL).toBe("debug");
+ });
+
+ it("throws when a required string variable is missing", () => {
+ delete process.env.ATHENA_WORKGROUP;
+
+ expect(() => createDependenciesContainer()).toThrow(ZodError);
+ });
+
+ it.each(["abc", "0", "1.5", ""])(
+ "rejects invalid STANDARD_SLA_WORKING_DAYS %p",
+ (value) => {
+ process.env.STANDARD_SLA_WORKING_DAYS = value;
+
+ expect(() => createDependenciesContainer()).toThrow();
+ },
+ );
+
+ it("rejects a missing DAYS_TO_LOOK_BACK", () => {
+ delete process.env.DAYS_TO_LOOK_BACK;
+
+ expect(() => createDependenciesContainer()).toThrow();
+ });
+});
diff --git a/lambdas/letter-sla-breaches/common/src/deps.ts b/lambdas/letter-sla-breaches/common/src/deps.ts
index 1a3ebc369..cc948000e 100644
--- a/lambdas/letter-sla-breaches/common/src/deps.ts
+++ b/lambdas/letter-sla-breaches/common/src/deps.ts
@@ -1,27 +1,20 @@
import { AthenaClient } from "@aws-sdk/client-athena";
import { createLogger } from "@internal/helpers";
import { Logger } from "pino";
+import { type EnvVars, parseEnvVars } from "./env";
export type Deps = {
athenaClient: AthenaClient;
logger: Logger;
- env: {
- ATHENA_WORKGROUP: string;
- ATHENA_DATABASE: string;
- EVENT_CACHE_TABLE_NAME: string;
- PACK_SPECIFICATION_TABLE_NAME: string;
- };
+ env: EnvVars;
};
export default function createDependenciesContainer(): Deps {
+ const env = parseEnvVars(process.env);
+
return {
athenaClient: new AthenaClient(),
- logger: createLogger({ logLevel: process.env.PINO_LOG_LEVEL }),
- env: {
- ATHENA_WORKGROUP: process.env.ATHENA_WORKGROUP!,
- ATHENA_DATABASE: process.env.ATHENA_DATABASE!,
- EVENT_CACHE_TABLE_NAME: process.env.EVENT_CACHE_TABLE_NAME!,
- PACK_SPECIFICATION_TABLE_NAME: process.env.PACK_SPECIFICATION_TABLE_NAME!,
- },
+ logger: createLogger({ logLevel: env.PINO_LOG_LEVEL }),
+ env,
};
}
diff --git a/lambdas/letter-sla-breaches/common/src/env.ts b/lambdas/letter-sla-breaches/common/src/env.ts
new file mode 100644
index 000000000..129b90af9
--- /dev/null
+++ b/lambdas/letter-sla-breaches/common/src/env.ts
@@ -0,0 +1,18 @@
+import { z } from "zod";
+
+const EnvVarsSchema = z.object({
+ ATHENA_WORKGROUP: z.string(),
+ ATHENA_DATABASE: z.string(),
+ EVENT_CACHE_TABLE_NAME: z.string(),
+ PACK_SPECIFICATION_TABLE_NAME: z.string(),
+ DAYS_TO_LOOK_BACK: z.coerce.number().int().positive(),
+ STANDARD_SLA_WORKING_DAYS: z.coerce.number().int().positive(),
+ PINO_LOG_LEVEL: z.string().optional(),
+});
+
+export type EnvVars = z.infer;
+
+// Parsed on demand rather than at import so that importing this package
+// (e.g. when automocked in tests) doesn't require the environment to be set
+export const parseEnvVars = (env: NodeJS.ProcessEnv): EnvVars =>
+ EnvVarsSchema.parse(env);
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
index 8875e4acc..15e760948 100644
--- a/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/package.json
@@ -1,6 +1,13 @@
{
"dependencies": {
- "esbuild": "0.28.2"
+ "@date-fns/tz": "catalog:app",
+ "@internal/helpers": "workspace:*",
+ "@internal/letter-sla-breaches-common": "workspace:*",
+ "aws-embedded-metrics": "catalog:aws",
+ "date-fns": "catalog:app"
+ },
+ "devDependencies": {
+ "@internal/sql-test-helpers": "workspace:*"
},
"name": "nhs-notify-supplier-api-populate-standard-breaches",
"private": true,
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
index f64093251..90a85a00c 100644
--- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/populate-standard-breaches.test.ts
@@ -1,12 +1,101 @@
-import populateStandardBreaches from "../handler/populate-standard-breaches";
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import { Unit } from "aws-embedded-metrics";
+import buildStandardBreachesQuery from "../handler/standard-breaches-query";
+import createHandler from "../handler/populate-standard-breaches";
+
+jest.mock("@internal/letter-sla-breaches-common");
+jest.mock("../handler/standard-breaches-query");
+
+const makeDeps = () =>
+ ({
+ logger: { info: jest.fn(), error: jest.fn() },
+ env: {
+ EVENT_CACHE_TABLE_NAME: "event_cache",
+ PACK_SPECIFICATION_TABLE_NAME: "pack_specification",
+ DAYS_TO_LOOK_BACK: 10,
+ STANDARD_SLA_WORKING_DAYS: 3,
+ },
+ }) as unknown as Deps;
describe("populateStandardBreaches", () => {
- it("returns a success result", async () => {
- const result = await populateStandardBreaches({ date: "2026-10-08" });
+ const oldEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...oldEnv };
+ });
+
+ afterEach(() => {
+ process.env = oldEnv;
+ });
+
+ it("passes the configured SLA days and look-back to the query", async () => {
+ jest.mocked(runAthenaQuery).mockResolvedValue([]);
+
+ await createHandler(makeDeps())({ date: "2026-10-08" });
+
+ expect(buildStandardBreachesQuery).toHaveBeenCalledWith(
+ "event_cache",
+ "pack_specification",
+ new Date("2026-10-08"),
+ 3,
+ 10,
+ );
+ });
+
+ it("runs the standard breaches query and logs a metric per supplier", async () => {
+ const deps = makeDeps();
+ jest.mocked(runAthenaQuery).mockResolvedValue([
+ { supplierid: "supplier1", breach_count: "10" },
+ { supplierid: "supplier2", breach_count: "5" },
+ ]);
+ process.env.AWS_LAMBDA_FUNCTION_NAME = "populate-standard-breaches";
+
+ await createHandler(deps)({ date: "2026-10-08" });
- expect(result).toEqual({
- status: "SUCCESS",
- message: "populate-standard-breaches invoked successfully",
- });
+ expect(deps.logger.info as jest.Mock).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ SupplierId: "supplier1",
+ _aws: expect.objectContaining({
+ CloudWatchMetrics: [
+ {
+ Namespace: "populate-standard-breaches",
+ Dimensions: [[], ["SupplierId"]],
+ Metrics: [
+ expect.objectContaining({
+ Name: "BreachCount",
+ Unit: Unit.Count,
+ }),
+ ],
+ },
+ ],
+ }),
+ BreachCount: 10,
+ }),
+ );
+ expect(deps.logger.info as jest.Mock).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ SupplierId: "supplier2",
+ _aws: expect.objectContaining({
+ CloudWatchMetrics: [
+ {
+ Namespace: "populate-standard-breaches",
+ Dimensions: [[], ["SupplierId"]],
+ Metrics: [
+ expect.objectContaining({
+ Name: "BreachCount",
+ Unit: Unit.Count,
+ }),
+ ],
+ },
+ ],
+ }),
+ BreachCount: 5,
+ }),
+ );
});
});
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts
new file mode 100644
index 000000000..d33007281
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/__tests__/standard-breaches-query.test.ts
@@ -0,0 +1,410 @@
+import {
+ TrinoContext,
+ runParameterisedQueryOnTrino,
+ runTrinoStatement,
+ setupTrinoContainer,
+} from "@internal/sql-test-helpers";
+import { TZDate } from "@date-fns/tz";
+import buildStandardBreachesQuery from "../handler/standard-breaches-query";
+
+type LetterEvent = {
+ domainid: string;
+ specificationid: string;
+ status: string;
+ supplierid: string;
+ time: string;
+};
+
+const EVENT_TABLE_NAME = "event_cache";
+const PACK_SPECIFICATION_TABLE_NAME = "pack_specification";
+
+async function insertEvents(context: TrinoContext, events: LetterEvent[]) {
+ const values = events
+ .map(({ domainid, specificationid, status, supplierid, time }) => {
+ const [year, month, day] = time.slice(0, 10).split("-");
+ return `('${domainid}', '${specificationid}', '${status}', '${supplierid}', '${time}', '${year}', '${month}', '${day}')`;
+ })
+ .join(",\n");
+
+ await runTrinoStatement(
+ context,
+ `INSERT INTO ${EVENT_TABLE_NAME} VALUES ${values}`,
+ );
+}
+
+async function insertPackSpecifications(
+ context: TrinoContext,
+ specifications: { id: string; features: string[] }[],
+) {
+ const packSpecifications = specifications.map((specification) => {
+ const featuresArray = `ARRAY['${specification.features.join("', '")}']`;
+ return `('${specification.id}', ROW(${featuresArray}))`;
+ });
+
+ await runTrinoStatement(
+ context,
+ `INSERT INTO ${PACK_SPECIFICATION_TABLE_NAME} VALUES ${packSpecifications}`,
+ );
+}
+
+function atZoneEuropeLondon(dateTime: string) {
+ const [year, month, day, hour, minute, second] = dateTime
+ .split(/[- :]/)
+ .map(Number);
+ return new TZDate(
+ year,
+ month - 1,
+ day,
+ hour,
+ minute,
+ second,
+ "Europe/London",
+ );
+}
+
+function sortBySupplier(rows: Record[]) {
+ return rows.toSorted((a, b) =>
+ (a.supplierid ?? "").localeCompare(b.supplierid ?? ""),
+ );
+}
+
+describe("buildStandardBreachesQuery", () => {
+ let context: TrinoContext;
+
+ beforeAll(async () => {
+ context = await setupTrinoContainer();
+
+ await runTrinoStatement(
+ context,
+ `CREATE TABLE ${PACK_SPECIFICATION_TABLE_NAME} (
+ id VARCHAR,
+ assembly ROW(features ARRAY(VARCHAR))
+ )`,
+ );
+ await insertPackSpecifications(context, [
+ { id: "standard", features: [] },
+ { id: "braille", features: ["BRAILLE"] },
+ { id: "audio", features: ["AUDIO"] },
+ { id: "same_day", features: ["SAME_DAY"] },
+ { id: "admail", features: ["ADMAIL"] },
+ ]);
+ }, 180_000);
+
+ afterAll(async () => {
+ await runTrinoStatement(
+ context,
+ `DROP TABLE IF EXISTS ${PACK_SPECIFICATION_TABLE_NAME}`,
+ );
+ await context?.container.stop();
+ });
+
+ beforeEach(async () => {
+ await runTrinoStatement(
+ context,
+ `CREATE TABLE ${EVENT_TABLE_NAME} (
+ domainid VARCHAR,
+ specificationid VARCHAR,
+ status VARCHAR,
+ supplierid VARCHAR,
+ time VARCHAR,
+ __year VARCHAR,
+ __month VARCHAR,
+ __day VARCHAR
+ )`,
+ );
+ });
+
+ afterEach(async () => {
+ await runTrinoStatement(
+ context,
+ `DROP TABLE IF EXISTS ${EVENT_TABLE_NAME}`,
+ );
+ });
+
+ it("counts letters per supplier where the SLA has been breached", async () => {
+ // After 2400 BST on Wednesday (i.e. early on Thursday)
+ const now = new Date("2026-10-15T01:01:00.000Z");
+
+ await insertEvents(context, [
+ // Breach: received on Monday, never completed
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-10-12T10:00:00.000Z",
+ },
+ // Not a breach: completed
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-10-12T10:00:00.000Z",
+ },
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "DISPATCHED",
+ time: "2026-10-13T10:00:00.000Z",
+ },
+ // Breach: a non-terminal status does not complete the letter
+ {
+ domainid: "letter-4",
+ supplierid: "supplier-b",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-10-12T10:00:00.000Z",
+ },
+ {
+ domainid: "letter-4",
+ supplierid: "supplier-b",
+ specificationid: "standard",
+ status: "ACCEPTED",
+ time: "2026-10-13T10:00:00.000Z",
+ },
+ // Breach: first PENDING event is used, even if repeated later
+ {
+ domainid: "letter-5",
+ supplierid: "supplier-b",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-10-12T11:00:00.000Z",
+ },
+ {
+ domainid: "letter-5",
+ supplierid: "supplier-b",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-10-13T11:00:00.000Z",
+ },
+ // Not a breach: each terminal status completes the letter
+ ...[
+ "REJECTED",
+ "FAILED",
+ "CANCELLED",
+ "FORWARDED",
+ "RETURNED",
+ "DELIVERED",
+ ].flatMap((status, i) => [
+ {
+ domainid: `letter-terminal-${i}`,
+ supplierid: "supplier-b",
+ status: "PENDING",
+ specificationid: "standard",
+ time: "2026-10-12T10:00:00.000Z",
+ },
+ {
+ domainid: `letter-terminal-${i}`,
+ supplierid: "supplier-b",
+ specificationid: "standard",
+ status,
+ time: "2026-10-13T10:00:00.000Z",
+ },
+ ]),
+ // Breaching, but ignored: before the 14-day window
+ {
+ domainid: "letter-6",
+ supplierid: "supplier-c",
+ specificationid: "standard",
+ status: "PENDING",
+ time: "2026-09-01T10:00:00.000Z",
+ },
+ ]);
+
+ const rows = await runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(
+ EVENT_TABLE_NAME,
+ PACK_SPECIFICATION_TABLE_NAME,
+ now,
+ 2,
+ 14,
+ ),
+ );
+
+ expect(sortBySupplier(rows)).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ { supplierid: "supplier-b", breach_count: "2" },
+ ]);
+ });
+
+ test("Letters breach after two working days, at the end of the day to allow time for Royal Mail collection", async () => {
+ await insertEvents(context, [
+ // Received Wednesday - won't breach until after 2400 BST on Friday
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: atZoneEuropeLondon("2026-10-14 11:00:00")
+ .withTimeZone("UTC")
+ .toISOString(),
+ },
+ // Received Thursday - won't breach until after 2400 BST on Monday
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: atZoneEuropeLondon("2026-10-15 11:00:00")
+ .withTimeZone("UTC")
+ .toISOString(),
+ },
+ // Received Friday - won't breach until after 2400 BST on Tuesday
+ {
+ domainid: "letter-3",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: atZoneEuropeLondon("2026-10-16 11:00:00")
+ .withTimeZone("UTC")
+ .toISOString(),
+ },
+ // Received Saturday - won't breach until after 2400 BST on Tuesday
+ {
+ domainid: "letter-4",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: atZoneEuropeLondon("2026-10-17 11:00:00")
+ .withTimeZone("UTC")
+ .toISOString(),
+ },
+ // Received Sunday - won't breach until after 2400 BST on Tuesday
+ {
+ domainid: "letter-5",
+ supplierid: "supplier-a",
+ specificationid: "standard",
+ status: "PENDING",
+ time: atZoneEuropeLondon("2026-10-18 11:00:00")
+ .withTimeZone("UTC")
+ .toISOString(),
+ },
+ ]);
+
+ const [
+ rowsBefore2400OnFriday,
+ rowsAfter2400OnFriday,
+ rowsAfter2400OnSaturday,
+ rowsAfter2400OnSunday,
+ rowsBefore2400OnMonday,
+ rowsAfter2400OnMonday,
+ rowsBefore2400OnTuesday,
+ rowsAfter2400OnTuesday,
+ ] = await Promise.all(
+ [
+ atZoneEuropeLondon("2026-10-16 23:59:59"),
+ atZoneEuropeLondon("2026-10-17 00:00:01"),
+ atZoneEuropeLondon("2026-10-18 00:00:01"),
+ atZoneEuropeLondon("2026-10-19 00:00:01"),
+ atZoneEuropeLondon("2026-10-19 23:59:59"),
+ atZoneEuropeLondon("2026-10-20 00:00:01"),
+ atZoneEuropeLondon("2026-10-20 23:59:59"),
+ atZoneEuropeLondon("2026-10-21 00:00:01"),
+ ].map(async (day) =>
+ runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(
+ EVENT_TABLE_NAME,
+ PACK_SPECIFICATION_TABLE_NAME,
+ day,
+ 2,
+ 14,
+ ),
+ ),
+ ),
+ );
+ expect(rowsBefore2400OnFriday).toHaveLength(0);
+ expect(rowsAfter2400OnFriday).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ ]);
+ expect(rowsAfter2400OnSaturday).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ ]);
+ expect(rowsAfter2400OnSunday).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ ]);
+ expect(rowsBefore2400OnMonday).toEqual([
+ { supplierid: "supplier-a", breach_count: "1" },
+ ]);
+ expect(rowsAfter2400OnMonday).toEqual([
+ { supplierid: "supplier-a", breach_count: "2" },
+ ]);
+ expect(rowsBefore2400OnTuesday).toEqual([
+ { supplierid: "supplier-a", breach_count: "2" },
+ ]);
+ expect(rowsAfter2400OnTuesday).toEqual([
+ { supplierid: "supplier-a", breach_count: "5" },
+ ]);
+ });
+
+ it("ignores same-day letters and letters in an accessible format", async () => {
+ const now = new Date("2026-10-14T12:00:00.000Z");
+
+ await insertEvents(context, [
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ specificationid: "same_day",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ {
+ domainid: "letter-2",
+ supplierid: "supplier-a",
+ specificationid: "braille",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ {
+ domainid: "letter-3",
+ supplierid: "supplier-a",
+ specificationid: "audio",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ ]);
+
+ const rows = await runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(
+ EVENT_TABLE_NAME,
+ PACK_SPECIFICATION_TABLE_NAME,
+ now,
+ 2,
+ 14,
+ ),
+ );
+
+ expect(rows).toHaveLength(0);
+ });
+
+ it("does not ignore admail", async () => {
+ const now = new Date("2026-10-14T12:00:00.000Z");
+
+ await insertEvents(context, [
+ {
+ domainid: "letter-1",
+ supplierid: "supplier-a",
+ specificationid: "admail",
+ status: "PENDING",
+ time: "2026-10-11T10:00:00.000Z",
+ },
+ ]);
+
+ const rows = await runParameterisedQueryOnTrino(
+ context,
+ buildStandardBreachesQuery(
+ EVENT_TABLE_NAME,
+ PACK_SPECIFICATION_TABLE_NAME,
+ now,
+ 2,
+ 14,
+ ),
+ );
+
+ expect(rows).toEqual([{ supplierid: "supplier-a", breach_count: "1" }]);
+ });
+});
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
index 1bcc2318c..2135c1018 100644
--- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/populate-standard-breaches.ts
@@ -1,18 +1,38 @@
+import {
+ type Deps,
+ runAthenaQuery,
+} from "@internal/letter-sla-breaches-common";
+import { Unit } from "aws-embedded-metrics";
+import { buildEMFObject } from "@internal/helpers";
+import buildStandardBreachesQuery from "./standard-breaches-query";
+
export type PopulateStandardBreachesEvent = {
date: string;
};
-export type PopulateStandardBreachesResult = {
- status: "SUCCESS";
- message: string;
-};
+export default function createHandler(deps: Deps) {
+ return async (event: PopulateStandardBreachesEvent): Promise => {
+ const sql = buildStandardBreachesQuery(
+ deps.env.EVENT_CACHE_TABLE_NAME,
+ deps.env.PACK_SPECIFICATION_TABLE_NAME,
+ new Date(event.date),
+ deps.env.STANDARD_SLA_WORKING_DAYS,
+ deps.env.DAYS_TO_LOOK_BACK,
+ );
+ const rows = await runAthenaQuery(deps, sql);
-// Placeholder: cache population is not yet implemented.
-export default async function populateStandardBreaches(
- _event: PopulateStandardBreachesEvent,
-): Promise {
- return {
- status: "SUCCESS",
- message: "populate-standard-breaches invoked successfully",
+ for (const row of rows) {
+ const emfObject = buildEMFObject(
+ "",
+ { SupplierId: row.supplierid! },
+ {
+ key: "BreachCount",
+ value: Number.parseInt(row.breach_count!, 10),
+ unit: Unit.Count,
+ },
+ [[], ["SupplierId"]],
+ );
+ deps.logger.info(emfObject);
+ }
};
}
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts
new file mode 100644
index 000000000..43c183307
--- /dev/null
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/handler/standard-breaches-query.ts
@@ -0,0 +1,55 @@
+import {
+ type AthenaQuery,
+ buildAthenaDatePredicate,
+} from "@internal/letter-sla-breaches-common";
+import { startOfDay, subBusinessDays } from "date-fns";
+import { TZDate } from "@date-fns/tz";
+
+export default function buildStandardBreachesQuery(
+ eventsTableName: string,
+ packSpecificationTableName: string,
+ now: Date,
+ slaWorkingDays: number,
+ daysToLookBack: number,
+): AthenaQuery {
+ const nowEuropeLondon = new TZDate(now, "Europe/London");
+ const slaCutOff = subBusinessDays(
+ startOfDay(nowEuropeLondon),
+ slaWorkingDays,
+ );
+ return {
+ queryString: `WITH parameters AS (
+ SELECT from_iso8601_timestamp(?) AS sla_cutoff
+),
+recent_letter_events AS (
+ SELECT domainid, supplierid, specificationid, status, from_iso8601_timestamp(time) AS event_time
+ FROM ${eventsTableName}
+ WHERE ${buildAthenaDatePredicate(now, daysToLookBack)}
+),
+allocated_letters AS (
+ SELECT domainid, supplierid, specificationid
+ FROM recent_letter_events
+ WHERE status = 'PENDING'
+ GROUP BY domainid, supplierid, specificationid
+ HAVING min(event_time) < (select sla_cutoff from parameters)
+),
+completed_letters AS (
+ SELECT domainid
+ FROM recent_letter_events
+ WHERE status in ('REJECTED', 'FAILED', 'CANCELLED', 'FORWARDED', 'DISPATCHED', 'RETURNED', 'DELIVERED')
+ GROUP BY domainid, supplierid
+)
+SELECT supplierid, count(*) AS breach_count
+FROM (
+ SELECT domainid, supplierid
+ FROM allocated_letters
+ WHERE domainid NOT IN (SELECT domainid FROM completed_letters)
+ AND cardinality(array_intersect(
+ (SELECT COALESCE(assembly.features, ARRAY[]) FROM ${packSpecificationTableName} spec WHERE spec.id = specificationid),
+ ARRAY['SAME_DAY', 'BRAILLE', 'AUDIO']
+ )) = 0
+)
+GROUP BY supplierid`,
+ executionParameters: [`'${slaCutOff.toISOString()}'`],
+ };
+}
diff --git a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts
index 5516fb886..f52283ddb 100644
--- a/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts
+++ b/lambdas/letter-sla-breaches/populate-standard-breaches/src/index.ts
@@ -1,2 +1,7 @@
+import { createDependenciesContainer } from "@internal/letter-sla-breaches-common";
+import createHandler from "./handler/populate-standard-breaches";
+
// eslint-disable-next-line import-x/prefer-default-export
-export { default as populateStandardBreachesHandler } from "./handler/populate-standard-breaches";
+export const populateStandardBreachesHandler = createHandler(
+ createDependenciesContainer(),
+);
diff --git a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json
index a41d1b78d..db2aad1b6 100644
--- a/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json
+++ b/lambdas/letter-sla-breaches/populate-supplier-config-cache/package.json
@@ -1,13 +1,13 @@
{
"dependencies": {
- "@aws-sdk/client-dynamodb": "^3.1138.0",
- "@aws-sdk/client-s3": "^3.1138.0",
- "@aws-sdk/lib-dynamodb": "^3.1138.0",
+ "@aws-sdk/client-dynamodb": "catalog:aws",
+ "@aws-sdk/client-s3": "catalog:aws",
+ "@aws-sdk/lib-dynamodb": "catalog:aws",
"@internal/datastore": "workspace:*",
"@internal/helpers": "workspace:*",
- "esbuild": "0.28.2",
- "pino": "^10.3.1",
- "zod": "^4.6.5"
+ "esbuild": "catalog:tools",
+ "pino": "catalog:app",
+ "zod": "catalog:app"
},
"name": "nhs-notify-supplier-api-populate-supplier-config-cache",
"private": true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6fa87dc58..082443169 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,6 +9,9 @@ catalogs:
'@asyncapi/bundler':
specifier: ^0.6.4
version: 0.6.4
+ '@date-fns/tz':
+ specifier: ^1.4.0
+ version: 1.5.0
'@nhsdigital/nhs-notify-event-schemas-letter-rendering':
specifier: ^2.0.2
version: 2.0.2
@@ -60,9 +63,6 @@ catalogs:
yargs:
specifier: ^18.2.0
version: 18.2.0
- zod:
- specifier: ^4.6.7
- version: 4.6.7
zod-mermaid:
specifier: ^1.0.9
version: 1.3.0
@@ -73,6 +73,9 @@ catalogs:
'@aws-sdk/client-api-gateway':
specifier: ^3.1138.0
version: 3.1149.0
+ '@aws-sdk/client-athena':
+ specifier: ^3.1138.0
+ version: 3.1152.0
'@aws-sdk/client-cloudwatch-logs':
specifier: ^3.1138.0
version: 3.1149.0
@@ -299,8 +302,8 @@ catalogs:
specifier: ^13.0.6
version: 13.0.6
js-yaml:
- specifier: ^5.4.2
- version: 5.4.3
+ specifier: ^5.5.0
+ version: 5.5.0
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0
@@ -337,6 +340,8 @@ overrides:
fast-xml-parser: ^5.5.6
flatted: ^3.4.0
ip-address: ^10.7.4
+ js-yaml@3: ^3.15.2
+ js-yaml@4: ^4.3.2
minimatch@10: ^10.2.4
minimatch@3: ^3.1.5
minimatch@5: ^5.1.9
@@ -346,6 +351,7 @@ overrides:
react: ^19.0.0
undici: ^7.30.1
uuid: ^14.0.0
+ zod: ^4.6.6
importers:
@@ -513,7 +519,7 @@ importers:
version: 4.0.2(@jest/globals@30.5.2)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3)
js-yaml:
specifier: catalog:tools
- version: 5.4.3
+ version: 5.5.0
lcov-result-merger:
specifier: catalog:test
version: 6.0.0
@@ -537,7 +543,7 @@ importers:
version: 12.2.0
ts-jest:
specifier: catalog:test
- version: 29.4.14(@babel/core@8.0.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@8.0.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: catalog:tools
version: 10.9.2(@types/node@26.6.5)(typescript@5.9.3)
@@ -554,7 +560,7 @@ importers:
specifier: catalog:tools
version: 2.9.1
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
docs:
@@ -581,7 +587,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
zod-mermaid:
specifier: catalog:app
@@ -602,7 +608,7 @@ importers:
specifier: catalog:app
version: 0.6.4
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
internal/helpers:
@@ -617,9 +623,15 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
+ internal/sql-test-helpers:
+ dependencies:
+ testcontainers:
+ specifier: catalog:test
+ version: 12.2.0
+
lambdas/api-handler:
dependencies:
'@aws-sdk/client-dynamodb':
@@ -665,7 +677,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/authorizer:
@@ -695,7 +707,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/eventpub-parquet-transformer:
@@ -710,14 +722,17 @@ importers:
lambdas/letter-sla-breaches/common:
dependencies:
'@aws-sdk/client-athena':
- specifier: ^3.1149.0
+ specifier: catalog:aws
version: 3.1152.0
'@internal/helpers':
specifier: workspace:*
version: link:../../../internal/helpers
pino:
- specifier: ^10.4.0
+ specifier: catalog:app
version: 10.4.0
+ zod:
+ specifier: ^4.6.6
+ version: 4.6.7
lambdas/letter-sla-breaches/populate-sameday-breaches:
dependencies:
@@ -736,20 +751,36 @@ importers:
lambdas/letter-sla-breaches/populate-standard-breaches:
dependencies:
- esbuild:
- specifier: 0.28.2
- version: 0.28.2
+ '@date-fns/tz':
+ specifier: catalog:app
+ version: 1.5.0
+ '@internal/helpers':
+ specifier: workspace:*
+ version: link:../../../internal/helpers
+ '@internal/letter-sla-breaches-common':
+ specifier: workspace:*
+ version: link:../common
+ aws-embedded-metrics:
+ specifier: catalog:aws
+ version: 4.2.1
+ date-fns:
+ specifier: catalog:app
+ version: 4.4.0
+ devDependencies:
+ '@internal/sql-test-helpers':
+ specifier: workspace:*
+ version: link:../../../internal/sql-test-helpers
lambdas/letter-sla-breaches/populate-supplier-config-cache:
dependencies:
'@aws-sdk/client-dynamodb':
- specifier: ^3.1138.0
+ specifier: catalog:aws
version: 3.1149.0
'@aws-sdk/client-s3':
- specifier: ^3.1138.0
+ specifier: catalog:aws
version: 3.1149.0
'@aws-sdk/lib-dynamodb':
- specifier: ^3.1138.0
+ specifier: catalog:aws
version: 3.1149.0(@aws-sdk/client-dynamodb@3.1149.0)
'@internal/datastore':
specifier: workspace:*
@@ -758,13 +789,13 @@ importers:
specifier: workspace:*
version: link:../../../internal/helpers
esbuild:
- specifier: 0.28.2
+ specifier: catalog:tools
version: 0.28.2
pino:
- specifier: ^10.3.1
+ specifier: catalog:app
version: 10.4.0
zod:
- specifier: ^4.6.5
+ specifier: ^4.6.6
version: 4.6.7
lambdas/letter-updates-transformer:
@@ -800,7 +831,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/mi-updates-transformer:
@@ -833,7 +864,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/supplier-allocator:
@@ -890,7 +921,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/supplier-config-ingress:
@@ -923,7 +954,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/supplier-mock:
@@ -953,7 +984,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
lambdas/update-letter-queue:
@@ -986,7 +1017,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
devDependencies:
'@tsconfig/node22':
@@ -1050,7 +1081,7 @@ importers:
specifier: catalog:app
version: 10.4.0
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
pact-contracts: {}
@@ -1210,7 +1241,7 @@ importers:
specifier: catalog:app
version: 8.11.2
zod:
- specifier: catalog:app
+ specifier: ^4.6.6
version: 4.6.7
devDependencies:
'@aws-sdk/client-cloudwatch-logs':
@@ -2146,6 +2177,9 @@ packages:
'@datastructures-js/heap@4.3.7':
resolution: {integrity: sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==}
+ '@date-fns/tz@1.5.0':
+ resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -3446,6 +3480,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ argparse@3.0.4:
+ resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==}
+
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -5053,8 +5090,8 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
- ip-address@10.7.4:
- resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==}
+ ip-address@10.7.5:
+ resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
@@ -5438,6 +5475,10 @@ packages:
resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==}
hasBin: true
+ js-yaml@5.5.0:
+ resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==}
+ hasBin: true
+
jsdom@26.1.0:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
@@ -7163,7 +7204,7 @@ packages:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
- zod: ^3.25.0 || ^4.0.0
+ zod: ^4.6.6
zod@4.6.7:
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
@@ -7361,7 +7402,7 @@ snapshots:
'@aws-sdk/credential-provider-env@3.972.73':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
'@smithy/types': 4.19.0
@@ -7377,7 +7418,7 @@ snapshots:
'@aws-sdk/credential-provider-http@3.972.75':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
'@smithy/fetch-http-handler': 5.8.0
@@ -7397,7 +7438,7 @@ snapshots:
'@aws-sdk/credential-provider-ini@3.973.18':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/credential-provider-env': 3.972.73
'@aws-sdk/credential-provider-http': 3.972.75
'@aws-sdk/credential-provider-login': 3.972.80
@@ -7429,8 +7470,8 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.80':
dependencies:
- '@aws-sdk/core': 3.978.2
- '@aws-sdk/nested-clients': 3.997.47
+ '@aws-sdk/core': 3.978.3
+ '@aws-sdk/nested-clients': 3.997.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
'@smithy/types': 4.19.0
@@ -7475,7 +7516,7 @@ snapshots:
'@aws-sdk/credential-provider-process@3.972.73':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
'@smithy/types': 4.19.0
@@ -7491,7 +7532,7 @@ snapshots:
'@aws-sdk/credential-provider-sso@3.973.17':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/nested-clients': 3.997.47
'@aws-sdk/token-providers': 3.1149.0
'@aws-sdk/types': 3.974.6
@@ -7511,7 +7552,7 @@ snapshots:
'@aws-sdk/credential-provider-web-identity@3.972.79':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/nested-clients': 3.997.47
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
@@ -7529,7 +7570,7 @@ snapshots:
'@aws-sdk/dynamodb-codec@3.973.47':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@smithy/core': 3.35.2
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7581,7 +7622,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.47':
dependencies:
- '@aws-sdk/core': 3.978.2
+ '@aws-sdk/core': 3.978.3
'@aws-sdk/signature-v4-multi-region': 3.996.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
@@ -7619,8 +7660,8 @@ snapshots:
'@aws-sdk/token-providers@3.1149.0':
dependencies:
- '@aws-sdk/core': 3.978.2
- '@aws-sdk/nested-clients': 3.997.47
+ '@aws-sdk/core': 3.978.3
+ '@aws-sdk/nested-clients': 3.997.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.2
'@smithy/types': 4.19.0
@@ -8507,6 +8548,8 @@ snapshots:
'@datastructures-js/heap@4.3.7': {}
+ '@date-fns/tz@1.5.0': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -10046,6 +10089,8 @@ snapshots:
argparse@2.0.1: {}
+ argparse@3.0.4: {}
+
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -12072,7 +12117,7 @@ snapshots:
hasown: 2.0.4
side-channel: 1.1.1
- ip-address@10.7.4: {}
+ ip-address@10.7.5: {}
ipaddr.js@1.9.1: {}
@@ -12652,6 +12697,10 @@ snapshots:
dependencies:
argparse: 2.0.1
+ js-yaml@5.5.0:
+ dependencies:
+ argparse: 3.0.4
+
jsdom@26.1.0:
dependencies:
cssstyle: 4.6.0
@@ -13735,7 +13784,7 @@ snapshots:
socks@2.8.10:
dependencies:
- ip-address: 10.7.4
+ ip-address: 10.7.5
smart-buffer: 4.2.0
sonic-boom@4.2.1:
@@ -14062,6 +14111,27 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ ts-jest@29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3):
+ dependencies:
+ bs-logger: 0.2.6
+ fast-json-stable-stringify: 2.1.0
+ handlebars: 4.7.10
+ jest: 30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3))
+ json5: 2.2.3
+ lodash.memoize: 4.1.2
+ make-error: 1.3.6
+ semver: 7.8.5
+ type-fest: 4.41.0
+ typescript: 5.9.3
+ yargs-parser: 21.1.1
+ optionalDependencies:
+ '@babel/core': 7.29.7
+ '@jest/transform': 30.5.2
+ '@jest/types': 30.5.1
+ babel-jest: 30.5.2(@babel/core@7.29.7)
+ esbuild: 0.28.2
+ jest-util: 30.5.1
+
ts-jest@29.4.14(@babel/core@8.0.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@8.0.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 39ce4cbc2..017c7dae8 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -28,6 +28,7 @@ catalogs:
"@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.1"
date-fns: "^4.4.0"
date-fns-tz: "^3.0.0"
+ "@date-fns/tz": "^1.4.0"
dotenv: "^18.0.3"
express: "^4.22.2"
graphql: "^17.0.2"
@@ -45,6 +46,7 @@ catalogs:
aws:
"@aws-lambda-powertools/idempotency": "^2.36.0"
"@aws-sdk/client-api-gateway": "^3.1138.0"
+ "@aws-sdk/client-athena": "^3.1138.0"
"@aws-sdk/client-cloudwatch-logs": "^3.1138.0"
"@aws-sdk/client-dynamodb": "^3.1138.0"
"@aws-sdk/client-kinesis": "^3.1138.0"
@@ -87,6 +89,7 @@ catalogs:
test:
"@babel/core": "^8.0.6"
"@babel/preset-env": "^8.0.6"
+ "browserslist": "4.28.7"
"@jest/globals": "^30.4.1"
"@nhsdigital/notify-digital-letters-consumer-contracts": "^1.0.3"
"@pact-foundation/pact": "^17.1.4"
@@ -122,7 +125,7 @@ catalogs:
esbuild: "0.28.2"
get-east-asian-width: "^1.7.0"
glob: "^13.0.6"
- js-yaml: "^5.4.2"
+ js-yaml: "^5.5.0"
openapi-typescript: "^7.13.0"
serve: "^14.2.4"
ts-node: "^10.9.2"
@@ -150,6 +153,8 @@ overrides:
fast-xml-parser: "^5.5.6"
flatted: "^3.4.0"
ip-address: "^10.7.4"
+ js-yaml@3: "^3.15.2"
+ js-yaml@4: "^4.3.2"
"minimatch@10": "^10.2.4"
"minimatch@3": "^3.1.5"
"minimatch@5": "^5.1.9"
@@ -159,3 +164,4 @@ overrides:
react: "^19.0.0"
undici: "^7.30.1"
uuid: "^14.0.0"
+ zod: "^4.6.6"
diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties
index cda56bc1d..6ad5ec975 100644
--- a/scripts/config/sonar-scanner.properties
+++ b/scripts/config/sonar-scanner.properties
@@ -9,7 +9,7 @@ sonar.exclusions=lambdas/**/src/__tests__/**/*
sonar.test.inclusions=**/*.test.*, **/*.spec.*
sonar.terraform.provider.aws.version=5.54.1
sonar.cpd.exclusions=**.test.*
-sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__,
+sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, internal/sql-test-helpers/**,
#sonar.python.coverage.reportPaths=.coverage/coverage.xml
sonar.javascript.lcov.reportPaths=lcov.info