diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml
index f734055cc..082f687b5 100644
--- a/.github/actions/acceptance-tests/action.yaml
+++ b/.github/actions/acceptance-tests/action.yaml
@@ -25,16 +25,23 @@ runs:
using: "composite"
steps:
+ - name: Get Node version
+ id: nodejs_version
+ shell: bash
+ run: |
+ echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT
+ - uses: ./.github/actions/node-install
+ with:
+ node-version: ${{ steps.nodejs_version.outputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
- name: "Repo setup"
shell: bash
run: |
npm ci
-
- name: "Generate dependencies"
shell: bash
run: |
npm run generate-dependencies
-
- name: Run test - ${{ inputs.testType }}
shell: bash
run: |
@@ -51,7 +58,6 @@ runs:
env:
TEST_TYPE: ${{ inputs.testType }}
ENVIRONMENT: ${{ inputs.targetEnvironment }}
-
- name: Archive integration test results
if: ${{ inputs.testType == 'integration' }}
uses: actions/upload-artifact@v4
diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml
index a005d111c..53f740330 100644
--- a/.github/actions/build-docs/action.yml
+++ b/.github/actions/build-docs/action.yml
@@ -4,14 +4,21 @@ inputs:
version:
description: "Version number"
required: true
+ node-version:
+ description: 'Node.js version'
+ required: true
+ GITHUB_TOKEN:
+ description: "Token for access to github package registry"
+ required: true
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/node-install
with:
- node-version: 24
+ node-version: ${{ inputs.node-version }}
+ GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }}
- name: Npm cli install
working-directory: ./docs
run: npm ci
diff --git a/.github/actions/node-install/action.yaml b/.github/actions/node-install/action.yaml
new file mode 100644
index 000000000..6ab529a58
--- /dev/null
+++ b/.github/actions/node-install/action.yaml
@@ -0,0 +1,25 @@
+name: 'Node install and setup'
+description: 'Setup node and authenticate github package repository'
+
+inputs:
+ node-version:
+ description: 'Node.js version'
+ required: true
+ GITHUB_TOKEN:
+ description: "Token for access to github package registry"
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+ - name: 'Use Node.js'
+ uses: actions/setup-node@v6
+ with:
+ node-version: '${{ inputs.node-version }}'
+
+ - name: "Configure npm for GitHub Packages"
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }}
+ run: |
+ scripts/set-github-token.sh
diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml
index 1f4b77211..ed943fc3a 100644
--- a/.github/workflows/cicd-1-pull-request.yaml
+++ b/.github/workflows/cicd-1-pull-request.yaml
@@ -15,12 +15,15 @@ on:
permissions:
id-token: write
contents: write
+ packages: read
jobs:
metadata:
name: "Set CI/CD metadata"
runs-on: ubuntu-latest
timeout-minutes: 1
+ permissions:
+ contents: read
outputs:
build_datetime_london: ${{ steps.variables.outputs.build_datetime_london }}
build_datetime: ${{ steps.variables.outputs.build_datetime }}
@@ -152,6 +155,9 @@ jobs:
name: Trigger dynamic environment creation
needs: [metadata, build-stage]
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
if: needs.metadata.outputs.does_pull_request_exist == 'true' || (github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened'))
steps:
- uses: actions/checkout@v5.0.0
diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml
index d5436d675..d8f3bb816 100644
--- a/.github/workflows/stage-1-commit.yaml
+++ b/.github/workflows/stage-1-commit.yaml
@@ -152,12 +152,15 @@ jobs:
uses: ./.github/actions/lint-terraform
trivy-iac:
name: "Trivy IaC Scan"
- permissions:
- contents: read
runs-on: ubuntu-latest
timeout-minutes: 10
needs: detect-terraform-changes
if: needs.detect-terraform-changes.outputs.terraform_changed == 'true'
+ permissions:
+ contents: read
+ packages: read
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: "Checkout code"
uses: actions/checkout@v4
@@ -168,10 +171,13 @@ jobs:
trivy-package:
if: ${{ !inputs.skip_trivy_package }}
name: "Trivy Package Scan"
- permissions:
- contents: read
runs-on: ubuntu-latest
timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: read
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: "Checkout code"
uses: actions/checkout@v4
diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml
index 3d4a8fa26..bc01b549f 100644
--- a/.github/workflows/stage-2-test.yaml
+++ b/.github/workflows/stage-2-test.yaml
@@ -40,21 +40,21 @@ env:
AWS_REGION: eu-west-2
TERM: xterm-256color
-permissions:
- id-token: write # This is required for requesting the JWT
- contents: read # This is required for actions/checkout
-
jobs:
check-generated-dependencies:
name: "Check generated dependencies"
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
+ packages: read
steps:
- name: "Checkout code"
uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/node-install
with:
- node-version: 24.10.0
+ node-version: ${{ inputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Repo setup"
run: |
npm ci
@@ -66,12 +66,16 @@ jobs:
name: "Unit tests"
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
+ packages: read
steps:
- name: "Checkout code"
uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/node-install
with:
- node-version: 24.10.0
+ node-version: ${{ inputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Setup Python"
uses: actions/setup-python@v6
with:
@@ -103,6 +107,9 @@ jobs:
name: "Linting"
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
+ packages: read
steps:
- name: "Checkout code"
uses: actions/checkout@v5
@@ -110,9 +117,10 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python_version }}
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/node-install
with:
- node-version: 24.10.0
+ node-version: ${{ inputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Run linting"
run: |
make test-lint
@@ -120,12 +128,18 @@ jobs:
name: "Typecheck"
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
+ packages: read
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: "Checkout code"
uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/node-install
with:
- node-version: 24.10.0
+ node-version: ${{ inputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Run typecheck"
run: |
make test-typecheck
@@ -134,6 +148,8 @@ jobs:
needs: [test-unit]
runs-on: ubuntu-latest
timeout-minutes: 5
+ permissions:
+ contents: read
steps:
- name: "Checkout code"
uses: actions/checkout@v5
diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml
index bc28487c1..e9d531949 100644
--- a/.github/workflows/stage-3-build.yaml
+++ b/.github/workflows/stage-3-build.yaml
@@ -44,3 +44,5 @@ jobs:
uses: ./.github/actions/build-docs
with:
version: "${{ inputs.version }}"
+ node-version: ${{ inputs.nodejs_version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..aab2808a0
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+# Package is scoped under @org, set registry for that scope
+@nhsdigital:registry=https://npm.pkg.github.com
diff --git a/Makefile b/Makefile
index 348ca3470..806321435 100644
--- a/Makefile
+++ b/Makefile
@@ -17,6 +17,7 @@ dependencies:: # Install dependencies needed to build and test the project @Pipe
$(MAKE) -C utils/metric-publishers install
$(MAKE) -C utils/event-publisher-py install
$(MAKE) -C utils/py-mock-mesh install
+ ./scripts/set-github-token.sh
npm install --workspaces
$(MAKE) generate
diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md
index 26aeafacd..f811d47a7 100644
--- a/infrastructure/terraform/components/dl/README.md
+++ b/infrastructure/terraform/components/dl/README.md
@@ -50,6 +50,7 @@ No requirements.
| [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [pdm\_uploader](#module\_pdm\_uploader) | 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 |
| [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
@@ -60,6 +61,7 @@ No requirements.
| [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/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\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/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.24/terraform-sqs.zip | n/a |
| [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf
new file mode 100644
index 000000000..d5344efed
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_print_status_changed.tf
@@ -0,0 +1,19 @@
+resource "aws_cloudwatch_event_rule" "print_status_changed" {
+ name = "${local.csi}-print-status-changed"
+ description = "Print status changed event rule"
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+
+ event_pattern = jsonencode({
+ "detail" : {
+ "type" : [{
+ "prefix" : "uk.nhs.notify.supplier-api.letter."
+ }]
+ }
+ })
+}
+
+resource "aws_cloudwatch_event_target" "print_status_changed_print_status_handler" {
+ rule = aws_cloudwatch_event_rule.print_status_changed.name
+ arn = module.sqs_print_status_handler.sqs_queue_arn
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+}
diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf
new file mode 100644
index 000000000..847476148
--- /dev/null
+++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_print_status_handler.tf
@@ -0,0 +1,10 @@
+resource "aws_lambda_event_source_mapping" "print_status_handler" {
+ event_source_arn = module.sqs_print_status_handler.sqs_queue_arn
+ function_name = module.print_status_handler.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/module_lambda_print_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf
new file mode 100644
index 000000000..498b72852
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf
@@ -0,0 +1,86 @@
+module "print_status_handler" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "print-status-handler"
+ description = "A function for processing letter printing statuses"
+
+ 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.print_status_handler.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 = "print-status-handler/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 = {
+ "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ }
+}
+
+data "aws_iam_policy_document" "print_status_handler" {
+ statement {
+ sid = "PutEvents"
+ effect = "Allow"
+
+ actions = [
+ "events:PutEvents",
+ ]
+
+ resources = [
+ aws_cloudwatch_event_bus.main.arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsDLQs"
+ effect = "Allow"
+
+ actions = [
+ "sqs:SendMessage",
+ "sqs:SendMessageBatch",
+ ]
+
+ resources = [
+ module.sqs_event_publisher_errors.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsPrintStatusHandlerQueue"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:GetQueueUrl",
+ ]
+
+ resources = [
+ module.sqs_print_status_handler.sqs_queue_arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf b/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf
new file mode 100644
index 000000000..075b59500
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_sqs_print_status_handler.tf
@@ -0,0 +1,36 @@
+module "sqs_print_status_handler" {
+ 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 = "print-status-handler"
+ sqs_kms_key_arn = module.kms.key_arn
+ visibility_timeout_seconds = 60
+ delay_seconds = 5
+ create_dlq = true
+ max_receive_count = 1
+ sqs_policy_overload = data.aws_iam_policy_document.sqs_print_status_handler.json
+}
+
+data "aws_iam_policy_document" "sqs_print_status_handler" {
+ 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}-print-status-handler-queue"
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/dl/pre.sh b/infrastructure/terraform/components/dl/pre.sh
index 373d14b05..4ef25bb37 100755
--- a/infrastructure/terraform/components/dl/pre.sh
+++ b/infrastructure/terraform/components/dl/pre.sh
@@ -4,6 +4,16 @@
# It ensures all Node.js dependencies are installed, generates any required dependencies,
# and builds all Lambda functions in the workspace before Terraform provisions infrastructure.
+echo "Running Pre.sh"
+
+ROOT_DIR="$(git rev-parse --show-toplevel)"
+
+echo "Running set-github-token.sh"
+
+$ROOT_DIR/scripts/set-github-token.sh
+
+echo "Completed."
+
npm ci
npm run generate-dependencies
diff --git a/lambdas/print-status-handler/jest.config.ts b/lambdas/print-status-handler/jest.config.ts
new file mode 100644
index 000000000..c1c729f3f
--- /dev/null
+++ b/lambdas/print-status-handler/jest.config.ts
@@ -0,0 +1,9 @@
+import { baseJestConfig } from '../../jest.config.base';
+
+const config = baseJestConfig;
+
+config.transformIgnorePatterns = [
+ 'node_modules/(?!@nhsdigital/nhs-notify-event-schemas-supplier-api)',
+];
+
+export default config;
diff --git a/lambdas/print-status-handler/package.json b/lambdas/print-status-handler/package.json
new file mode 100644
index 000000000..b5bd7f43a
--- /dev/null
+++ b/lambdas/print-status-handler/package.json
@@ -0,0 +1,27 @@
+{
+ "dependencies": {
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6",
+ "aws-lambda": "^1.0.7",
+ "digital-letters-events": "^0.0.1",
+ "utils": "^0.0.1",
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ },
+ "name": "nhs-notify-digital-letters-print-status-handler",
+ "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/print-status-handler/src/__tests__/apis/sqs-handler.test.ts b/lambdas/print-status-handler/src/__tests__/apis/sqs-handler.test.ts
new file mode 100644
index 000000000..65c344a7a
--- /dev/null
+++ b/lambdas/print-status-handler/src/__tests__/apis/sqs-handler.test.ts
@@ -0,0 +1,178 @@
+import { mock } from 'jest-mock-extended';
+import { randomUUID } from 'node:crypto';
+import { createHandler } from 'apis/sqs-handler';
+import { EventPublisher, Logger } from 'utils';
+import { acceptedLetterEvent, recordEvent } from '__tests__/test-data';
+
+const logger = mock();
+const eventPublisher = mock();
+
+jest.mock('node:crypto', () => ({
+ randomUUID: jest.fn(),
+}));
+
+const mockRandomUUID = randomUUID as jest.MockedFunction;
+const mockDate = jest.spyOn(Date.prototype, 'toISOString');
+mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001');
+mockDate.mockReturnValue('2023-06-20T12:00:00.250Z');
+
+const handler = createHandler({
+ eventPublisher,
+ logger,
+});
+
+describe('SQS Handler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('letter status transitions', () => {
+ it('should send print.letter.transitioned event when letter.ACCEPTED received', async () => {
+ const response = await handler(recordEvent([acceptedLetterEvent]));
+
+ expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
+ [
+ {
+ ...acceptedLetterEvent,
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ time: '2023-06-20T12:00:00.250Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.json',
+ type: 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1',
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/print',
+ data: {
+ senderId: acceptedLetterEvent.data.origin.subject.split('/')[1],
+ messageReference:
+ acceptedLetterEvent.data.origin.subject.split('/')[3],
+ specificationId: acceptedLetterEvent.data.specificationId,
+ status: acceptedLetterEvent.data.status,
+ supplierId: acceptedLetterEvent.data.supplierId,
+ time: acceptedLetterEvent.time,
+ },
+ },
+ ],
+ expect.any(Function),
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Received SQS Event of 1 record(s)',
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ '1 of 1 records processed successfully',
+ );
+ expect(response).toEqual({ batchItemFailures: [] });
+ });
+ });
+
+ describe('errors', () => {
+ it('should return failed SQS records to the queue if an error occurs while processing them', async () => {
+ const event = recordEvent([acceptedLetterEvent]);
+ event.Records[0].body = 'not-json';
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: new SyntaxError(
+ `Unexpected token 'o', "not-json" is not valid JSON`,
+ ),
+ description: 'Error parsing SQS record',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: '1' }],
+ });
+ });
+
+ it('should return failed items to the queue if an invalid letter.ACCEPTED event is received', async () => {
+ const invalidAcceptedLetterEvent = {
+ ...acceptedLetterEvent,
+ source: 'invalid letter.ACCEPTED source',
+ };
+ const event = recordEvent([invalidAcceptedLetterEvent]);
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: expect.objectContaining({
+ issues: expect.arrayContaining([
+ expect.objectContaining({
+ path: ['source'],
+ }),
+ ]),
+ }),
+ description: 'Error parsing queue item',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: '1' }],
+ });
+ });
+
+ it('should return failed items to the queue if an invalid origin.subject event is received', async () => {
+ const invalidAcceptedLetterEvent = {
+ ...acceptedLetterEvent,
+ data: {
+ ...acceptedLetterEvent.data,
+ origin: {
+ ...acceptedLetterEvent.data.origin,
+ subject: 'invalid origin.subject',
+ },
+ },
+ };
+ const event = recordEvent([invalidAcceptedLetterEvent]);
+
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: expect.objectContaining({
+ issues: expect.arrayContaining([
+ expect.objectContaining({
+ message:
+ 'Subject must be in format: client/{senderId}/digital-letters/{messageReference}',
+ }),
+ ]),
+ }),
+ description: 'Invalid origin.subject format',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: '1' }],
+ });
+ });
+
+ it('should return failed items to the queue if event transformation fails', async () => {
+ mockRandomUUID.mockImplementationOnce(() => {
+ throw new Error('A forced error scenario');
+ });
+
+ const event = recordEvent([acceptedLetterEvent]);
+ const result = await handler(event);
+
+ expect(logger.warn).toHaveBeenCalledWith({
+ err: 'A forced error scenario',
+ description: 'Failed processing message',
+ });
+
+ expect(logger.info).toHaveBeenCalledWith(
+ '0 of 1 records processed successfully',
+ );
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: '1' }],
+ });
+ });
+ });
+});
diff --git a/lambdas/print-status-handler/src/__tests__/container.test.ts b/lambdas/print-status-handler/src/__tests__/container.test.ts
new file mode 100644
index 000000000..64f1a694d
--- /dev/null
+++ b/lambdas/print-status-handler/src/__tests__/container.test.ts
@@ -0,0 +1,22 @@
+import { createContainer } from 'container';
+
+jest.mock('infra/config', () => ({
+ loadConfig: jest.fn(() => ({
+ eventPublisherDlqUrl: 'test-url',
+ eventPublisherEventBusArn: 'test-arn',
+ })),
+}));
+
+jest.mock('utils', () => ({
+ eventBridgeClient: {},
+ EventPublisher: jest.fn(() => ({})),
+ logger: {},
+ sqsClient: {},
+}));
+
+describe('container', () => {
+ it('should create container', () => {
+ const container = createContainer();
+ expect(container).toBeDefined();
+ });
+});
diff --git a/lambdas/print-status-handler/src/__tests__/index.test.ts b/lambdas/print-status-handler/src/__tests__/index.test.ts
new file mode 100644
index 000000000..b5465321a
--- /dev/null
+++ b/lambdas/print-status-handler/src/__tests__/index.test.ts
@@ -0,0 +1,15 @@
+import { handler } from 'index';
+
+jest.mock('apis/sqs-handler', () => ({
+ createHandler: jest.fn(() => jest.fn()),
+}));
+
+jest.mock('container', () => ({
+ createContainer: jest.fn(() => ({})),
+}));
+
+describe('index', () => {
+ it('should export handler', () => {
+ expect(handler).toBeDefined();
+ });
+});
diff --git a/lambdas/print-status-handler/src/__tests__/infra/config.test.ts b/lambdas/print-status-handler/src/__tests__/infra/config.test.ts
new file mode 100644
index 000000000..2902c80f9
--- /dev/null
+++ b/lambdas/print-status-handler/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();
+ });
+});
diff --git a/lambdas/print-status-handler/src/__tests__/test-data.ts b/lambdas/print-status-handler/src/__tests__/test-data.ts
new file mode 100644
index 000000000..4dcd09f54
--- /dev/null
+++ b/lambdas/print-status-handler/src/__tests__/test-data.ts
@@ -0,0 +1,64 @@
+import { SQSEvent, SQSRecord } from 'aws-lambda';
+import { LetterEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events';
+
+export const acceptedLetterEvent = {
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ specversion: '1.0',
+ source: '/data-plane/supplier-api/prod/update-status',
+ subject:
+ 'letter-origin/digital-letters/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ type: 'uk.nhs.notify.supplier-api.letter.ACCEPTED.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.ACCEPTED.1.0.0.schema.json',
+ dataschemaversion: '1.0.0',
+ time: '2023-06-20T12:00:00Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ severitynumber: 2,
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ datacontenttype: 'application/json',
+ severitytext: 'INFO',
+ plane: 'data',
+ data: {
+ domainId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ groupId: 'client_template',
+ origin: {
+ domain: 'letter-rendering',
+ event: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ source: '/data-plane/letter-rendering/prod/render-pdf',
+ subject:
+ 'client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/b9c0c7f8-8204-400d-8348-7e7ddf775dae',
+ },
+ specificationId: '1y3q9v1zzzz',
+ supplierId: 'supplier-1',
+ status: 'ACCEPTED',
+ },
+} as LetterEvent;
+
+const busEvent = {
+ version: '0',
+ id: 'ab07d406-0797-e919-ff9b-3ad9c5498114',
+};
+
+const sqsRecord = {
+ messageId: '1',
+ receiptHandle: 'abc',
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '2025-07-03T14:23:30Z',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z',
+ },
+ messageAttributes: {},
+ md5OfBody: '',
+ eventSource: 'aws:sqs',
+ eventSourceARN: '',
+ awsRegion: '',
+} as SQSRecord;
+
+export const recordEvent = (events: LetterEvent[]): SQSEvent => ({
+ Records: events.map((event, i) => ({
+ ...sqsRecord,
+ messageId: String(i + 1),
+ body: JSON.stringify({ ...busEvent, detail: event }),
+ })),
+});
diff --git a/lambdas/print-status-handler/src/apis/sqs-handler.ts b/lambdas/print-status-handler/src/apis/sqs-handler.ts
new file mode 100644
index 000000000..141fc6c26
--- /dev/null
+++ b/lambdas/print-status-handler/src/apis/sqs-handler.ts
@@ -0,0 +1,165 @@
+import type {
+ SQSBatchItemFailure,
+ SQSBatchResponse,
+ SQSEvent,
+} from 'aws-lambda';
+import { randomUUID } from 'node:crypto';
+import { z } from 'zod';
+import {
+ $LetterEvent,
+ LetterEvent,
+} from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events';
+import { PrintLetterTransitioned } from 'digital-letters-events';
+import printLetterTransitionedValidator from 'digital-letters-events/PrintLetterTransitioned.js';
+import { EventPublisher, Logger } from 'utils';
+
+export interface HandlerDependencies {
+ eventPublisher: EventPublisher;
+ logger: Logger;
+}
+
+type ValidatedRecord = {
+ messageId: string;
+ event: LetterEvent;
+};
+
+const originSubjectSchema = z
+ .string()
+ .regex(
+ /^client\/[^/]+\/digital-letters\/[^/]+$/,
+ 'Subject must be in format: client/{senderId}/digital-letters/{messageReference}',
+ );
+
+function validateRecord(
+ { body, messageId }: { body: string; messageId: string },
+ logger: Logger,
+): ValidatedRecord | null {
+ try {
+ const sqsEventBody = JSON.parse(body);
+ const sqsEventDetail = sqsEventBody.detail;
+
+ const {
+ data: item,
+ error: parseError,
+ success: parseSuccess,
+ } = $LetterEvent.safeParse(sqsEventDetail);
+
+ if (!parseSuccess) {
+ logger.warn({
+ err: parseError,
+ description: 'Error parsing queue item',
+ });
+
+ return null;
+ }
+
+ const subjectValidation = originSubjectSchema.safeParse(
+ item.data.origin.subject,
+ );
+
+ if (!subjectValidation.success) {
+ logger.warn({
+ err: subjectValidation.error,
+ description: 'Invalid origin.subject format',
+ });
+
+ return null;
+ }
+
+ return { messageId, event: item };
+ } catch (error) {
+ logger.warn({
+ err: error,
+ description: 'Error parsing SQS record',
+ });
+
+ return null;
+ }
+}
+
+function generateUpdatedEvent(event: LetterEvent): PrintLetterTransitioned {
+ const eventTime = new Date().toISOString();
+
+ const {
+ data: {
+ origin: { subject },
+ specificationId,
+ status,
+ supplierId,
+ },
+ time,
+ } = event;
+
+ const senderId = subject.split('/')[1];
+ const messageReference = subject.split('/')[3];
+
+ return {
+ ...event,
+ id: randomUUID(),
+ time: eventTime,
+ recordedtime: eventTime,
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.json',
+ type: 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1',
+ // NOTE: CCM-13892 Generate event digital letters source property from scratch
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/print',
+ data: {
+ senderId,
+ messageReference,
+ specificationId,
+ status,
+ supplierId,
+ time,
+ },
+ };
+}
+
+export const createHandler = ({
+ eventPublisher,
+ logger,
+}: HandlerDependencies) =>
+ async function handler(sqsEvent: SQSEvent): Promise {
+ const receivedItemCount = sqsEvent.Records.length;
+ const batchItemFailures: SQSBatchItemFailure[] = [];
+ const validatedRecords: ValidatedRecord[] = [];
+ const validEvents: PrintLetterTransitioned[] = [];
+
+ logger.info(`Received SQS Event of ${receivedItemCount} record(s)`);
+
+ for (const record of sqsEvent.Records) {
+ const validated = validateRecord(record, logger);
+ if (validated) {
+ validatedRecords.push(validated);
+ } else {
+ batchItemFailures.push({ itemIdentifier: record.messageId });
+ }
+ }
+
+ await Promise.all(
+ validatedRecords.map(async (validatedRecord: ValidatedRecord) => {
+ try {
+ const { event } = validatedRecord;
+ validEvents.push(generateUpdatedEvent(event));
+ } catch (error: any) {
+ logger.warn({
+ err: error.message,
+ description: 'Failed processing message',
+ });
+ batchItemFailures.push({ itemIdentifier: validatedRecord.messageId });
+ }
+ }),
+ );
+
+ await eventPublisher.sendEvents(
+ validEvents,
+ printLetterTransitionedValidator,
+ );
+
+ const processedItemCount = receivedItemCount - batchItemFailures.length;
+ logger.info(
+ `${processedItemCount} of ${receivedItemCount} records processed successfully`,
+ );
+
+ return { batchItemFailures };
+ };
diff --git a/lambdas/print-status-handler/src/container.ts b/lambdas/print-status-handler/src/container.ts
new file mode 100644
index 000000000..7fe49378d
--- /dev/null
+++ b/lambdas/print-status-handler/src/container.ts
@@ -0,0 +1,19 @@
+import { HandlerDependencies } from 'apis/sqs-handler';
+import { loadConfig } from 'infra/config';
+import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils';
+
+export const createContainer = (): HandlerDependencies => {
+ const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig();
+
+ const eventPublisher = new EventPublisher({
+ eventBusArn: eventPublisherEventBusArn,
+ dlqUrl: eventPublisherDlqUrl,
+ logger,
+ sqsClient,
+ eventBridgeClient,
+ });
+
+ return { eventPublisher, logger };
+};
+
+export default createContainer;
diff --git a/lambdas/print-status-handler/src/index.ts b/lambdas/print-status-handler/src/index.ts
new file mode 100644
index 000000000..f25a80861
--- /dev/null
+++ b/lambdas/print-status-handler/src/index.ts
@@ -0,0 +1,6 @@
+import { createHandler } from 'apis/sqs-handler';
+import { createContainer } from 'container';
+
+export const handler = createHandler(createContainer());
+
+export default handler;
diff --git a/lambdas/print-status-handler/src/infra/config.ts b/lambdas/print-status-handler/src/infra/config.ts
new file mode 100644
index 000000000..855e66108
--- /dev/null
+++ b/lambdas/print-status-handler/src/infra/config.ts
@@ -0,0 +1,17 @@
+import { defaultConfigReader } from 'utils';
+
+export type Config = {
+ eventPublisherEventBusArn: string;
+ eventPublisherDlqUrl: string;
+};
+
+export function loadConfig(): Config {
+ return {
+ eventPublisherEventBusArn: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ ),
+ eventPublisherDlqUrl: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_DLQ_URL',
+ ),
+ };
+}
diff --git a/lambdas/print-status-handler/tsconfig.json b/lambdas/print-status-handler/tsconfig.json
new file mode 100644
index 000000000..f7bcaa1ff
--- /dev/null
+++ b/lambdas/print-status-handler/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 07ad56f2b..8bb825262 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"lambdas/ttl-poll-lambda",
"lambdas/pdm-uploader-lambda",
"lambdas/core-notifier-lambda",
+ "lambdas/print-status-handler",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
@@ -428,6 +429,78 @@
"lambdas/print-sender-lambda": {
"extraneous": true
},
+ "lambdas/print-status-handler": {
+ "name": "nhs-notify-digital-letters-print-status-handler",
+ "version": "0.0.1",
+ "dependencies": {
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6",
+ "aws-lambda": "^1.0.7",
+ "digital-letters-events": "^0.0.1",
+ "utils": "^0.0.1",
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/print-status-handler/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/print-status-handler/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",
+ "peer": true,
+ "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/print-status-handler/node_modules/jest-mock-extended": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz",
+ "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ts-essentials": "^10.0.0"
+ },
+ "peerDependencies": {
+ "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0",
+ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
"lambdas/refresh-apim-access-token": {
"version": "0.0.1",
"dependencies": {
@@ -722,6 +795,36 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/@asyncapi/bundler": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/@asyncapi/bundler/-/bundler-0.6.4.tgz",
+ "integrity": "sha512-lKZo2FF2TKt4n6Qm8vP/JOEEGE04gdH/D9oHmBt/NfOylMaw8XoFsI+k+IJyzpVMzREjZfxGf9gNzfW0CWRf5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "^11.5.4",
+ "@types/json-schema": "^7.0.11",
+ "@ungap/structured-clone": "^1.2.0",
+ "js-yaml": "^4.1.0",
+ "lodash": "^4.17.21"
+ }
+ },
+ "node_modules/@asyncapi/bundler/node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "11.9.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
+ "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ }
+ },
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"license": "Apache-2.0",
@@ -3918,6 +4021,16 @@
"node": ">= 6"
}
},
+ "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": {
+ "version": "1.0.6",
+ "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-api/1.0.6/075ecd7a7a79fd34c80ec7f79f9c885a125ce2a4",
+ "integrity": "sha512-kGqY3ANW/eHhrt21ZIRubkTENvBJiB0PL1zOmSJ+3fuJz/K6q3sNZuQEyJipreq/bmy06Jntgu1mboLRQa8Ilw==",
+ "license": "MIT",
+ "dependencies": {
+ "@asyncapi/bundler": "^0.6.4",
+ "zod": "^4.1.11"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"license": "MIT",
@@ -5347,7 +5460,8 @@
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
- "dev": true,
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
@@ -12356,6 +12470,10 @@
"resolved": "lambdas/pdm-uploader-lambda",
"link": true
},
+ "node_modules/nhs-notify-digital-letters-print-status-handler": {
+ "resolved": "lambdas/print-status-handler",
+ "link": true
+ },
"node_modules/nhs-notify-digital-letters-ttl-create-lambda": {
"resolved": "lambdas/ttl-create-lambda",
"link": true
@@ -15256,6 +15374,7 @@
"@aws-sdk/lib-dynamodb": "3.844.0",
"@aws-sdk/util-dynamodb": "^3.933.0",
"@faker-js/faker": "^9.6.0",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6",
"@playwright/test": "^1.51.1",
"digital-letters-events": "^0.0.1",
"sender-management": "^0.0.1",
diff --git a/package.json b/package.json
index 4e4c5e124..125f8fb2c 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"lambdas/ttl-poll-lambda",
"lambdas/pdm-uploader-lambda",
"lambdas/core-notifier-lambda",
+ "lambdas/print-status-handler",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
diff --git a/scripts/set-github-token.sh b/scripts/set-github-token.sh
new file mode 100755
index 000000000..5e4179a48
--- /dev/null
+++ b/scripts/set-github-token.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+npm config ls -l | grep '/npm.pkg.github.com/:_authToken' -q && echo "Github token already exists" && exit 0
+
+if [[ -z "${GITHUB_TOKEN:-}" ]]; then
+ read -p "Enter GitHub token: " GITHUB_TOKEN
+ export GITHUB_TOKEN
+fi
+
+npm config --location user set //npm.pkg.github.com/:_authToken $GITHUB_TOKEN
diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml
index 0550f42f7..b4dbfebb4 100644
--- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml
+++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml
@@ -8,6 +8,8 @@ properties:
$ref: ../defs/requests.schema.yaml#/properties/senderId
messageReference:
$ref: ../defs/requests.schema.yaml#/properties/messageReference
+ specificationId:
+ $ref: ../defs/print.schema.yaml#/properties/specificationId
status:
$ref: ../defs/print.schema.yaml#/properties/status
supplierId:
diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml
index a718f0ebd..c5fb2160e 100644
--- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml
+++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/print.schema.yaml
@@ -13,6 +13,11 @@ properties:
- "DISPATCHED"
- "FAILED"
- "RETURNED"
+ - "PENDING"
+ - "ENCLOSED"
+ - "CANCELLED"
+ - "FORWARDED"
+ - "DELIVERED"
examples:
- ACCEPTED
- DISPATCHED
@@ -22,6 +27,11 @@ properties:
description: Identifier of the print supplier handling the print job
examples:
- "supplier-12345"
+ specificationId:
+ type: string
+ description: Reference to the letter specification which was used to produce a letter pack for this request
+ examples:
+ - "1y3q9v1zzzz"
pageCount:
type: integer
description: The number of pages in the analysed letter PDF.
diff --git a/tests/playwright/config/playwright.config.ts b/tests/playwright/config/playwright.config.ts
index d625cb362..9d85ebcb1 100644
--- a/tests/playwright/config/playwright.config.ts
+++ b/tests/playwright/config/playwright.config.ts
@@ -3,12 +3,12 @@ import path from 'node:path';
export default defineConfig({
testDir: path.resolve(__dirname, '../'),
- fullyParallel: false,
+ fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
retries: 0,
/* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 4 : undefined,
+ workers: process.env.CI ? 8 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['line'],
diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts
index 0af36d564..ebe9c1f9c 100644
--- a/tests/playwright/constants/backend-constants.ts
+++ b/tests/playwright/constants/backend-constants.ts
@@ -19,6 +19,8 @@ export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`;
export const PDM_UPLOADER_DLQ_NAME = `${CSI}-pdm-uploader-dlq`;
export const PDM_POLL_DLQ_NAME = `${CSI}-pdm-poll-dlq`;
export const CORE_NOTIFIER_DLQ_NAME = `${CSI}-core-notifier-dlq`;
+export const PRINT_STATUS_HANDLER_DLQ_NAME = `${CSI}-print-status-handler-dlq`;
+export const HANDLE_TTL_DLQ_NAME = `${CSI}-ttl-handle-expiry-errors-queue`;
// Queue Url Prefix
export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`;
@@ -38,3 +40,4 @@ export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGIO
export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`;
export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`;
export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-notifier`;
+export const PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-status-handler`;
diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts
index fe5e001bf..c1c413b77 100644
--- a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts
+++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts
@@ -9,7 +9,7 @@ import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceU
import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
import eventPublisher from 'helpers/event-bus-helpers';
import expectToPassEventually from 'helpers/expectations';
-import { expectMessageContainingString } from 'helpers/sqs-helpers';
+import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
import { v4 as uuidv4 } from 'uuid';
const baseEvent = {
@@ -43,6 +43,7 @@ const unavailableEvent = {
test.describe('PDM Poll', () => {
test.beforeAll(async () => {
test.setTimeout(250_000);
+ await purgeQueue(PDM_POLL_DLQ_NAME);
});
test.describe('pdm.resource.submitted', () => {
diff --git a/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts
new file mode 100644
index 000000000..bade8f061
--- /dev/null
+++ b/tests/playwright/digital-letters-component-tests/print-status-handler.component.spec.ts
@@ -0,0 +1,138 @@
+import { expect, test } from '@playwright/test';
+import { LetterEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events';
+import {
+ ENV,
+ PRINT_STATUS_HANDLER_DLQ_NAME,
+ PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME,
+} from 'constants/backend-constants';
+import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
+import eventPublisher from 'helpers/event-bus-helpers';
+import expectToPassEventually from 'helpers/expectations';
+import { v4 as uuidv4 } from 'uuid';
+import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
+
+const baseLetterEvent = {
+ id: '550e8400-e29b-41d4-a716-446655440001',
+ specversion: '1.0',
+ source: '/data-plane/supplier-api/prod/update-status',
+ subject:
+ 'letter-origin/digital-letters/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ dataschemaversion: '1.0.0',
+ time: '2023-06-20T12:00:00Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ severitynumber: 2,
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ datacontenttype: 'application/json',
+ severitytext: 'INFO',
+ plane: 'data',
+ data: {
+ domainId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ groupId: 'client_template',
+ origin: {
+ domain: 'letter-rendering',
+ event: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ source: '/data-plane/letter-rendering/prod/render-pdf',
+ },
+ specificationId: '1y3q9v1zzzz',
+ supplierId: 'supplier-1',
+ },
+} as LetterEvent;
+
+const letterStatuses = [
+ 'ACCEPTED',
+ 'REJECTED',
+ 'PRINTED',
+ 'DISPATCHED',
+ 'FAILED',
+ 'RETURNED',
+ 'PENDING',
+ 'ENCLOSED',
+ 'CANCELLED',
+ 'FORWARDED',
+ 'DELIVERED',
+] as const;
+
+test.describe('Print status handler', () => {
+ test.beforeAll(async () => {
+ test.setTimeout(150_000);
+ await purgeQueue(PRINT_STATUS_HANDLER_DLQ_NAME);
+ });
+
+ for (const status of letterStatuses) {
+ test(`should create print.letter.transitioned ${status} event for a letters.${status} event`, async () => {
+ const messageReference = uuidv4();
+ const letterEvent = {
+ ...baseLetterEvent,
+ type: `uk.nhs.notify.supplier-api.letter.${status}.v1`,
+ dataschema: `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.${status}.1.0.0.schema.json`,
+ data: {
+ ...baseLetterEvent.data,
+ status,
+ origin: {
+ ...baseLetterEvent.data.origin,
+ subject: `client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/${messageReference}`,
+ },
+ },
+ };
+
+ await eventPublisher.sendEvents([letterEvent], () => true);
+
+ await expectToPassEventually(async () => {
+ const eventLogEntry = await getLogsFromCloudwatch(
+ `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.print.letter.transitioned.v1"',
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"status\\":\\"${status}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 120);
+ });
+ }
+
+ test('should send invalid event to print status handler dlq', async () => {
+ test.setTimeout(250_000);
+
+ const messageReference = uuidv4();
+
+ // Send letter.ACCEPTED event with no data.status
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...baseLetterEvent,
+ type: `uk.nhs.notify.supplier-api.letter.ACCEPTED.v1`,
+ dataschema: `https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.ACCEPTED.1.0.0.schema.json`,
+ data: {
+ ...baseLetterEvent.data,
+ origin: {
+ ...baseLetterEvent.data.origin,
+ subject: `client/00f3b388-bbe9-41c9-9e76-052d37ee8988/digital-letters/${messageReference}`,
+ },
+ },
+ },
+ ],
+ () => true,
+ );
+
+ await expectToPassEventually(async () => {
+ const eventLogEntry = await getLogsFromCloudwatch(
+ PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME,
+ [
+ String.raw`$.message.err.message = "*Invalid option: expected one of \\\"PENDING\\\"*"`,
+ '$.message.description = "Error parsing queue item"',
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 120);
+
+ await expectMessageContainingString(
+ PRINT_STATUS_HANDLER_DLQ_NAME,
+ messageReference,
+ 120,
+ );
+ });
+});
diff --git a/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts b/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts
index 295f011d4..66c031319 100644
--- a/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts
+++ b/tests/playwright/digital-letters-component-tests/ttl-handle.component.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { ENV } from 'constants/backend-constants';
+import { ENV, HANDLE_TTL_DLQ_NAME } from 'constants/backend-constants';
import { MESHInboxMessageDownloaded } from 'digital-letters-events';
import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers';
@@ -8,10 +8,8 @@ import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
import { v4 as uuidv4 } from 'uuid';
test.describe('Digital Letters - Handle TTL', () => {
- const handleTtlDlqName = `nhs-${ENV}-dl-ttl-handle-expiry-errors-queue`;
-
test.beforeAll(async () => {
- await purgeQueue(handleTtlDlqName);
+ await purgeQueue(HANDLE_TTL_DLQ_NAME);
});
const baseEvent: MESHInboxMessageDownloaded = {
@@ -146,6 +144,6 @@ test.describe('Digital Letters - Handle TTL', () => {
const deleteResponseCode = await deleteTtl(messageUri);
expect(deleteResponseCode).toBe(200);
- await expectMessageContainingString(handleTtlDlqName, letterId);
+ await expectMessageContainingString(HANDLE_TTL_DLQ_NAME, letterId);
});
});
diff --git a/tests/playwright/package.json b/tests/playwright/package.json
index a1c7e3524..8113a26b8 100644
--- a/tests/playwright/package.json
+++ b/tests/playwright/package.json
@@ -8,6 +8,7 @@
"@aws-sdk/lib-dynamodb": "3.844.0",
"@aws-sdk/util-dynamodb": "^3.933.0",
"@faker-js/faker": "^9.6.0",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.6",
"@playwright/test": "^1.51.1",
"digital-letters-events": "^0.0.1",
"sender-management": "^0.0.1",