From 27ce6ff706d7583b39fc7d81c332860ef8b98698 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 11 May 2026 10:25:13 +0100 Subject: [PATCH 01/17] CCM-17639: Updated event to include reasonCode and text --- README.md | 4 +++- ...gital-letter-unsuccessful-data.schema.yaml | 12 +++++------ src/cloudevents/readme-index.yaml | 21 +++++++++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 557127960..9fd89fcf3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ _No common schemas defined yet._ | **Digital Letters Print Letter Transitioned Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-print-letter-transitioned-data.schema.md) | | **Digital Letters Print Pdf Analysed Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-print-pdf-analysed-data.schema.md) | | **Digital Letters Queue Digital Letter Read Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.md) | +| **Digital Letters Queue Digital Letter Unsuccessful Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.md) | | **Digital Letters Queue Item Dequeued Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.md) | | **Digital Letters Queue Item Enqueued Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.md) | | **Digital Letters Reporting Generate Report Data** | [`src/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml`](src/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.yaml) | [`schemas/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.json`](schemas/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-reporting-generate-report-data.schema.md) | @@ -171,13 +172,13 @@ _No common schemas defined yet._ | **uk.nhs.notify.digital.letters.print.letter.transitioned.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1.schema.md) | | **uk.nhs.notify.digital.letters.print.pdf.analysed.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1.schema.md) | | **uk.nhs.notify.digital.letters.queue.digital.letter.read.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.md) | +| **uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.md) | | **uk.nhs.notify.digital.letters.queue.item.dequeued.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.md) | | **uk.nhs.notify.digital.letters.queue.item.enqueued.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1.schema.md) | | **uk.nhs.notify.digital.letters.reporting.generate.report.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.generate.report.v1.schema.md) | | **uk.nhs.notify.digital.letters.reporting.report.generated.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.generated.v1.schema.md) | | **uk.nhs.notify.digital.letters.reporting.report.sent.v1** | [`src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.yaml`](src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.yaml) | [`schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.json`](schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.reporting.report.sent.v1.schema.md) | | **Nhs Notify Document Reference** | [`src/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.yaml`](src/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.yaml) | [`schemas/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.json`](schemas/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.md) | -| **Profile** | [`src/digital-letters/2025-10-draft/supplierapi-profile.schema.yaml`](src/digital-letters/2025-10-draft/supplierapi-profile.schema.yaml) | [`schemas/digital-letters/2025-10-draft/supplierapi-profile.schema.json`](schemas/digital-letters/2025-10-draft/supplierapi-profile.schema.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/supplierapi-profile.schema.md`](../../docs/cloudevents/digital-letters/2025-10-draft/supplierapi-profile.schema.md) | #### Example Events @@ -202,6 +203,7 @@ _No common schemas defined yet._ | **Uk.nhs.notify.digital.letters.print.letter.transitioned.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.letter.transitioned.v1-event.md) | | **Uk.nhs.notify.digital.letters.print.pdf.analysed.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.print.pdf.analysed.v1-event.md) | | **Uk.nhs.notify.digital.letters.queue.digital.letter.read.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.md) | +| **Uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.md) | | **Uk.nhs.notify.digital.letters.queue.item.dequeued.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event.md) | | **Uk.nhs.notify.digital.letters.queue.item.enqueued.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.enqueued.v1-event.md) | | **Uk.nhs.notify.digital.letters.reporting.generate.report.v1** | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.reporting.generate.report.v1-event.json`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.reporting.generate.report.v1-event.json) | [`../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.reporting.generate.report.v1-event.md`](../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.reporting.generate.report.v1-event.md) | diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml index d8e30a06c..525b6349e 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml @@ -8,12 +8,12 @@ properties: $ref: ../defs/requests.schema.yaml#/properties/messageReference senderId: $ref: ../defs/requests.schema.yaml#/properties/senderId - failureCode: - $ref: ../defs/core.schema.yaml#/properties/failureCode - failureReason: - $ref: ../defs/core.schema.yaml#/properties/failureReason + reasonCode: + $ref: ../defs/print.schema.yaml#/properties/reasonCode + reasonText: + $ref: ../defs/print.schema.yaml#/properties/reasonText required: - messageReference - senderId - - failureCode - - failureReason + - reasonCode + - reasonText diff --git a/src/cloudevents/readme-index.yaml b/src/cloudevents/readme-index.yaml index 4643324c0..dca43983e 100644 --- a/src/cloudevents/readme-index.yaml +++ b/src/cloudevents/readme-index.yaml @@ -3,7 +3,7 @@ # To regenerate, run: make update-readme # To customize labels and purposes, edit: readme-metadata.yaml -generated: '2026-04-09T13:48:22.150Z' +generated: '2026-05-11T09:16:35.071Z' common: null domains: - name: digital-letters @@ -112,6 +112,11 @@ domains: source: src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.yaml published: schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json docs: ../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.md + - type: Digital Letters Queue Digital Letter Unsuccessful Data + category: data + source: src/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.yaml + published: schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json + docs: ../../docs/cloudevents/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.md - type: Digital Letters Queue Item Dequeued Data category: data source: src/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.yaml @@ -327,6 +332,11 @@ domains: source: src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.yaml published: schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.json docs: ../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1.schema.md + - type: uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1 + category: events + source: src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.yaml + published: schemas/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.json + docs: ../../docs/cloudevents/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1.schema.md - type: uk.nhs.notify.digital.letters.queue.item.dequeued.v1 category: events source: src/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1.schema.yaml @@ -357,11 +367,6 @@ domains: source: src/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.yaml published: schemas/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.json docs: ../../docs/cloudevents/digital-letters/2025-10-draft/nhs-notify-document-reference.schema.md - - type: Profile - category: profile - source: src/digital-letters/2025-10-draft/supplierapi-profile.schema.yaml - published: schemas/digital-letters/2025-10-draft/supplierapi-profile.schema.json - docs: ../../docs/cloudevents/digital-letters/2025-10-draft/supplierapi-profile.schema.md exampleEvents: - name: Uk.nhs.notify.digital.letters.letter.available.v1 filename: uk.nhs.notify.digital.letters.letter.available.v1-event @@ -439,6 +444,10 @@ domains: filename: uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event json: ../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.json markdown: ../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.read.v1-event.md + - name: Uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1 + filename: uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event + json: ../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.json + markdown: ../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1-event.md - name: Uk.nhs.notify.digital.letters.queue.item.dequeued.v1 filename: uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event json: ../../docs/cloudevents/digital-letters/2025-10-draft/example-events/uk.nhs.notify.digital.letters.queue.item.dequeued.v1-event.json From 3bf60be83f9bc9e0d03215473b903c3784c0fb6b Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 09:18:46 +0100 Subject: [PATCH 02/17] CCM-17639: Initial commit --- ...tch_event_rule_message_status_published.tf | 24 ++ .../dl/module_lambda_nhsapp_status_handler.tf | 1 + .../dl/module_sqs_nhsapp_status_handler.tf | 5 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 269 +++++++++++- .../src/__tests__/container.test.ts | 2 + .../src/__tests__/data.ts | 29 +- .../src/apis/sqs-trigger-lambda.ts | 400 +++++++++++++----- .../nhsapp-status-handler/src/container.ts | 9 +- .../nhsapp-status-handler/src/infra/config.ts | 6 +- .../src/types/channel-status-failed-event.ts | 16 + utils/utils/src/types/index.ts | 2 + .../src/types/message-status-failed-event.ts | 16 + 12 files changed, 665 insertions(+), 114 deletions(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf create mode 100644 utils/utils/src/types/channel-status-failed-event.ts create mode 100644 utils/utils/src/types/message-status-failed-event.ts diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf new file mode 100644 index 000000000..b78fe4bae --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_message_status_published.tf @@ -0,0 +1,24 @@ +resource "aws_cloudwatch_event_rule" "message_status_published" { + name = "${local.csi}-message-status-published" + description = "message status PUBLISHED event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.message.status.PUBLISHED.v1" + ], + "data" : { + "messageStatus" : [ + "failed" + ] + } + } + }) +} + +resource "aws_cloudwatch_event_target" "sqs_nhsapp_status_handler_message_status_target" { + rule = aws_cloudwatch_event_rule.message_status_published.name + arn = module.sqs_nhsapp_status_handler.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf index a4e1d6b26..97ed9facd 100644 --- a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf @@ -38,6 +38,7 @@ module "nhsapp_status_handler" { "TTL_TABLE_NAME" = aws_dynamodb_table.ttl.name "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "CLOUD_EVENT_SOURCE" = "/nhs/england/notify/${var.environment}/${var.group}/digitalletters/queue" } } diff --git a/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf index 00136cac7..5141d4375 100644 --- a/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_sqs_nhsapp_status_handler.tf @@ -35,7 +35,10 @@ data "aws_iam_policy_document" "sqs_nhsapp_status_handler" { condition { test = "ArnLike" variable = "aws:SourceArn" - values = [aws_cloudwatch_event_rule.channel_status_published.arn] + values = [ + aws_cloudwatch_event_rule.channel_status_published.arn, + aws_cloudwatch_event_rule.message_status_published.arn, + ] } } } diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 01c208e22..bd7c4c198 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,9 +1,15 @@ -import { messageDownloadedEvent, nhsAppStatusEvent } from '__tests__/data'; +import { + channelStatusFailedEvent, + messageDownloadedEvent, + messageStatusFailedEvent, + nhsAppStatusEvent, +} from '__tests__/data'; import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; import { DigitalLetterRead, validateDigitalLetterRead, + validateDigitalLetterUnsuccessful, } from 'digital-letters-events'; import { randomUUID } from 'node:crypto'; @@ -41,11 +47,19 @@ describe('createHandler', () => { }, }; + const cloudEventSource = + '/nhs/england/notify/production/primary/digitalletters/queue'; + beforeEach(() => { ttlActions = { markWithdrawn: jest.fn() }; eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; logger = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; - handler = createHandler({ ttlActions, eventPublisher, logger }); + handler = createHandler({ + ttlActions, + eventPublisher, + logger, + cloudEventSource, + }); }); it('processes a valid SQS event and returns success', async () => { @@ -80,7 +94,7 @@ describe('createHandler', () => { }); }); - it('handles event validation failure and logs error', async () => { + it('handles unknown event type and logs warning', async () => { const event: SQSEvent = { Records: [{ body: '{}', messageId: 'msg1' }], } as any; @@ -90,7 +104,7 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ - description: expect.stringContaining('Error parsing sqs record'), + description: 'Error parsing sqs record', messageReference: 'not present', }), ); @@ -102,7 +116,7 @@ describe('createHandler', () => { }); }); - it('handles event validation failure and logs error with message reference if present', async () => { + it('handles unknown event type with message reference present', async () => { const messageReference = randomUUID(); const event: SQSEvent = { Records: [ @@ -118,7 +132,7 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ - description: expect.stringContaining('Error parsing sqs record'), + description: 'Error parsing sqs record', messageReference, }), ); @@ -130,6 +144,27 @@ describe('createHandler', () => { }); }); + it('handles unknown event type with non-string message reference', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify({ detail: { data: { messageReference: 123 } } }), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing sqs record', + messageReference: 'not present', + }), + ); + }); + it('handles ttlActions.markWithdrawn failure', async () => { ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { @@ -357,4 +392,226 @@ describe('createHandler', () => { success: 1, }); }); + + describe('channel status failed events', () => { + const channelFailedBusEvent = { + detail: channelStatusFailedEvent, + }; + + it('publishes DigitalLetterUnsuccessful for channel status failed event', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(channelFailedBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + specversion: '1.0', + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + source: cloudEventSource, + data: { + messageReference: channelStatusFailedEvent.data.messageReference, + senderId: channelStatusFailedEvent.data.clientId, + reasonCode: + channelStatusFailedEvent.data.channelFailureReasonCode, + reasonText: channelStatusFailedEvent.data.channelFailedReason, + }, + }), + ], + validateDigitalLetterUnsuccessful, + ); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Channel status failed event received', + messageReference: channelStatusFailedEvent.data.messageReference, + channelFailureReasonCode: + channelStatusFailedEvent.data.channelFailureReasonCode, + }); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + success: 1, + }); + }); + + it('handles DigitalLetterUnsuccessful publish failure for channel status', async () => { + eventPublisher.sendEvents.mockRejectedValue( + new Error('EventBridge error'), + ); + + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(channelFailedBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.any(Error), + description: + 'Failed to send DigitalLetterUnsuccessful events to EventBridge', + eventCount: 1, + }); + }); + + it('logs warning when some DigitalLetterUnsuccessful events fail to publish', async () => { + eventPublisher.sendEvents.mockResolvedValue([{ id: 'failed-event' }]); + + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(channelFailedBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith({ + description: 'Some DigitalLetterUnsuccessful events failed to publish', + failedCount: 1, + totalAttempted: 1, + }); + }); + }); + + describe('message status failed events', () => { + const messageFailedBusEvent = { + detail: messageStatusFailedEvent, + }; + + it('publishes DigitalLetterUnsuccessful for message status failed event', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(messageFailedBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(ttlActions.markWithdrawn).not.toHaveBeenCalled(); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + specversion: '1.0', + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + source: cloudEventSource, + data: { + messageReference: messageStatusFailedEvent.data.messageReference, + senderId: messageStatusFailedEvent.data.clientId, + reasonCode: + messageStatusFailedEvent.data.messageFailureReasonCode, + reasonText: + messageStatusFailedEvent.data.messageStatusDescription, + }, + }), + ], + validateDigitalLetterUnsuccessful, + ); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Message status failed event received', + messageReference: messageStatusFailedEvent.data.messageReference, + messageFailureReasonCode: + messageStatusFailedEvent.data.messageFailureReasonCode, + }); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 1, + success: 1, + }); + }); + + it('handles DigitalLetterUnsuccessful publish failure for message status', async () => { + eventPublisher.sendEvents.mockRejectedValue( + new Error('EventBridge error'), + ); + + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify(messageFailedBusEvent), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.any(Error), + description: + 'Failed to send DigitalLetterUnsuccessful events to EventBridge', + eventCount: 1, + }); + }); + }); + + describe('mixed event types', () => { + it('handles channel status opted out and channel status failed in same batch', async () => { + ttlActions.markWithdrawn.mockResolvedValue({ + result: 'success', + ttlItem: { event: messageDownloadedEvent }, + }); + + const event: SQSEvent = { + Records: [ + { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, + { + body: JSON.stringify({ detail: channelStatusFailedEvent }), + messageId: 'msg2', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([]); + expect(ttlActions.markWithdrawn).toHaveBeenCalledTimes(1); + expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(2); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [digitalLetterReadEvent], + validateDigitalLetterRead, + ); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + }), + ], + validateDigitalLetterUnsuccessful, + ); + expect(logger.info).toHaveBeenCalledWith({ + description: 'Processed SQS Event.', + failed: 0, + retrieved: 2, + success: 2, + }); + }); + }); }); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts index d52ce0228..d7c9b7799 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts @@ -5,6 +5,8 @@ jest.mock('infra/config', () => ({ eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', ttlTableName: 'test-table', + cloudEventSource: + '/nhs/england/notify/production/primary/digitalletters/queue', })), })); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/data.ts b/lambdas/nhsapp-status-handler/src/__tests__/data.ts index 6235d612b..e5fdd6571 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/data.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/data.ts @@ -1,5 +1,9 @@ import { MESHInboxMessageDownloaded } from 'digital-letters-events'; -import { ChannelStatusPublishedEvent } from 'utils'; +import { + ChannelStatusFailedEvent, + ChannelStatusPublishedEvent, + MessageStatusFailedEvent, +} from 'utils'; export const messageDownloadedEvent: MESHInboxMessageDownloaded = { id: '550e8400-e29b-41d4-a716-446655440001', @@ -32,3 +36,26 @@ export const nhsAppStatusEvent: ChannelStatusPublishedEvent = { supplierStatus: 'paper_letter_opted_out', }, }; + +export const channelStatusFailedEvent: ChannelStatusFailedEvent = { + data: { + messageReference: 'sender1_ref1', + clientId: 'sender1', + channelStatus: 'failed', + supplierStatus: 'rejected', + channelFailureReasonCode: 'NO_NHS_APP_ACCOUNT', + channelFailedReason: + 'Patient does not have an NHS App account or the App installed', + }, +}; + +export const messageStatusFailedEvent: MessageStatusFailedEvent = { + data: { + messageReference: 'sender1_ref1', + clientId: 'sender1', + messageStatus: 'failed', + messageStatusDescription: 'PDS enrichment failed for the patient', + messageFailureReasonCode: 'PDS_ENRICHMENT_FAILED', + channels: [], + }, +}; diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts index 93b4d3a74..8b5fc987e 100644 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts @@ -5,135 +5,331 @@ import type { } from 'aws-lambda'; import { randomUUID } from 'node:crypto'; import type { TtlActionOutcome, TtlActions } from 'app/ttl-actions'; -import { $ChannelStatusPublishedEvent, EventPublisher, Logger } from 'utils'; +import { + $ChannelStatusFailedEvent, + $ChannelStatusPublishedEvent, + $MessageStatusFailedEvent, + EventPublisher, + Logger, +} from 'utils'; import { DigitalLetterRead, + DigitalLetterUnsuccessful, MESHInboxMessageDownloaded, validateDigitalLetterRead, + validateDigitalLetterUnsuccessful, } from 'digital-letters-events'; interface ProcessingResult { outcome: TtlActionOutcome; + unsuccessfulEventData?: { + messageReference: string; + senderId: string; + reasonCode: string; + reasonText: string; + }; } +type UnsuccessfulEventData = NonNullable< + ProcessingResult['unsuccessfulEventData'] +>; + +type RecordProcessingResult = ProcessingResult & { + shouldFail: boolean; + messageId: string; +}; + interface CreateHandlerDependencies { ttlActions: TtlActions; eventPublisher: EventPublisher; logger: Logger; + cloudEventSource: string; } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const parseFailedMessageReference = (sqsEventDetail: unknown): string => { + if (!isRecord(sqsEventDetail)) { + return 'not present'; + } + + const { data } = sqsEventDetail; + if (!isRecord(data)) { + return 'not present'; + } + + return typeof data.messageReference === 'string' + ? data.messageReference + : 'not present'; +}; + +const processRecord = async ( + body: string, + messageId: string, + logger: Logger, + ttlActions: TtlActions, +): Promise => { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + + const channelStatusResult = + $ChannelStatusPublishedEvent.safeParse(sqsEventDetail); + + if (channelStatusResult.success) { + const result = await ttlActions.markWithdrawn(channelStatusResult.data); + + if (result.result === 'failed') { + return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + } + + return { outcome: result, shouldFail: false, messageId }; + } + + const channelFailedResult = + $ChannelStatusFailedEvent.safeParse(sqsEventDetail); + + if (channelFailedResult.success) { + const { data } = channelFailedResult; + + logger.info({ + description: 'Channel status failed event received', + messageReference: data.data.messageReference, + channelFailureReasonCode: data.data.channelFailureReasonCode, + }); + + return { + outcome: { result: 'success', ttlItem: undefined }, + unsuccessfulEventData: { + messageReference: data.data.messageReference, + senderId: data.data.clientId, + reasonCode: data.data.channelFailureReasonCode, + reasonText: data.data.channelFailedReason, + }, + shouldFail: false, + messageId, + }; + } + + const messageFailedResult = + $MessageStatusFailedEvent.safeParse(sqsEventDetail); + + if (messageFailedResult.success) { + const { data } = messageFailedResult; + + logger.info({ + description: 'Message status failed event received', + messageReference: data.data.messageReference, + messageFailureReasonCode: data.data.messageFailureReasonCode, + }); + + return { + outcome: { result: 'success', ttlItem: undefined }, + unsuccessfulEventData: { + messageReference: data.data.messageReference, + senderId: data.data.clientId, + reasonCode: data.data.messageFailureReasonCode, + reasonText: data.data.messageStatusDescription, + }, + shouldFail: false, + messageId, + }; + } + + logger.warn({ + err: channelStatusResult.error, + messageReference: parseFailedMessageReference(sqsEventDetail), + description: 'Error parsing sqs record', + }); + + return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + } catch (error) { + logger.warn({ + err: error, + description: 'Error during SQS trigger handler', + }); + + return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + } +}; + +const collectProcessingResults = ( + results: PromiseSettledResult[], + logger: Logger, +) => { + const processed: Record = { + retrieved: results.length, + success: 0, + failed: 0, + }; + const batchItemFailures: SQSBatchItemFailure[] = []; + const successfulEvents: MESHInboxMessageDownloaded[] = []; + const unsuccessfulEvents: UnsuccessfulEventData[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + const { messageId, outcome, shouldFail, unsuccessfulEventData } = + result.value; + + processed[outcome.result] += 1; + + if (shouldFail) { + batchItemFailures.push({ itemIdentifier: messageId }); + } + + if (outcome.result === 'success' && outcome.ttlItem) { + successfulEvents.push(outcome.ttlItem.event); + } + + if (unsuccessfulEventData) { + unsuccessfulEvents.push(unsuccessfulEventData); + } + } else { + logger.warn({ err: result.reason }); + processed.failed += 1; + } + } + + return { + processed, + batchItemFailures, + successfulEvents, + unsuccessfulEvents, + }; +}; + +const publishDigitalLetterReadEvents = async ( + eventPublisher: EventPublisher, + logger: Logger, + successfulEvents: MESHInboxMessageDownloaded[], +): Promise => { + if (successfulEvents.length === 0) { + return; + } + + try { + const failedEvents = await eventPublisher.sendEvents( + successfulEvents.map((event) => ({ + ...event, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', + source: event.source.replace(/\/mesh$/, '/queue'), + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + }, + })), + validateDigitalLetterRead, + ); + + if (failedEvents.length > 0) { + logger.warn({ + description: 'Some events failed to publish', + failedCount: failedEvents.length, + totalAttempted: successfulEvents.length, + }); + } + } catch (error) { + logger.warn({ + err: error, + description: 'Failed to send events to EventBridge', + eventCount: successfulEvents.length, + }); + } +}; + +const publishDigitalLetterUnsuccessfulEvents = async ( + eventPublisher: EventPublisher, + logger: Logger, + cloudEventSource: string, + unsuccessfulEvents: UnsuccessfulEventData[], +): Promise => { + if (unsuccessfulEvents.length === 0) { + return; + } + + try { + const failedEvents = + await eventPublisher.sendEvents( + unsuccessfulEvents.map((eventData) => ({ + specversion: '1.0' as const, + id: randomUUID(), + source: cloudEventSource, + subject: `message/${eventData.messageReference}`, + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1' as const, + plane: 'data' as const, + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + datacontenttype: 'application/json' as const, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-unsuccessful-data.schema.json' as const, + dataschemaversion: '1.0.0' as const, + traceparent: + '00-00000000000000000000000000000000-0000000000000000-00', + severitynumber: 3, + data: { + messageReference: eventData.messageReference, + senderId: eventData.senderId, + reasonCode: eventData.reasonCode, + reasonText: eventData.reasonText, + }, + })), + validateDigitalLetterUnsuccessful, + ); + + if (failedEvents.length > 0) { + logger.warn({ + description: 'Some DigitalLetterUnsuccessful events failed to publish', + failedCount: failedEvents.length, + totalAttempted: unsuccessfulEvents.length, + }); + } + } catch (error) { + logger.warn({ + err: error, + description: + 'Failed to send DigitalLetterUnsuccessful events to EventBridge', + eventCount: unsuccessfulEvents.length, + }); + } +}; + export const createHandler = ({ + cloudEventSource, eventPublisher, logger, ttlActions, }: CreateHandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { - const batchItemFailures: SQSBatchItemFailure[] = []; - - const promises = sqsEvent.Records.map( - async ({ body, messageId }): Promise => { - try { - const sqsEventBody = JSON.parse(body); - const sqsEventDetail = sqsEventBody.detail; - - const { - data: item, - error: parseError, - success: parseSuccess, - } = $ChannelStatusPublishedEvent.safeParse(sqsEventDetail); - - if (!parseSuccess) { - logger.warn({ - err: parseError, - messageReference: - sqsEventDetail?.data?.messageReference || 'not present', - description: 'Error parsing sqs record', - }); - - batchItemFailures.push({ itemIdentifier: messageId }); - return { outcome: { result: 'failed' } }; - } - - const result = await ttlActions.markWithdrawn(item); - - if (result.result === 'failed') { - batchItemFailures.push({ itemIdentifier: messageId }); - return { outcome: { result: 'failed' } }; - } - - return { outcome: result }; - } catch (error) { - logger.warn({ - err: error, - description: 'Error during SQS trigger handler', - }); - - batchItemFailures.push({ itemIdentifier: messageId }); - - return { outcome: { result: 'failed' } }; - } - }, + const promises = sqsEvent.Records.map(({ body, messageId }) => + processRecord(body, messageId, logger, ttlActions), ); const results = await Promise.allSettled(promises); - const processed: Record = - { - retrieved: results.length, - success: 0, - failed: 0, - }; - - const successfulEvents: MESHInboxMessageDownloaded[] = []; + const { + batchItemFailures, + processed, + successfulEvents, + unsuccessfulEvents, + } = collectProcessingResults(results, logger); - for (const result of results) { - if (result.status === 'fulfilled') { - const { outcome } = result.value; - processed[outcome.result] += 1; - - if (outcome.result === 'success' && outcome.ttlItem) { - successfulEvents.push(outcome.ttlItem.event); - } - } else { - logger.warn({ err: result.reason }); - processed.failed += 1; - } - } - - if (successfulEvents.length > 0) { - try { - const failedEvents = await eventPublisher.sendEvents( - successfulEvents.map((event) => ({ - ...event, - id: randomUUID(), - time: new Date().toISOString(), - recordedtime: new Date().toISOString(), - type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', - source: event.source.replace(/\/mesh$/, '/queue'), - data: { - messageReference: event.data.messageReference, - senderId: event.data.senderId, - }, - })), - validateDigitalLetterRead, - ); - if (failedEvents.length > 0) { - logger.warn({ - description: 'Some events failed to publish', - failedCount: failedEvents.length, - totalAttempted: successfulEvents.length, - }); - } - } catch (error) { - logger.warn({ - err: error, - description: 'Failed to send events to EventBridge', - eventCount: successfulEvents.length, - }); - } - } + await publishDigitalLetterReadEvents( + eventPublisher, + logger, + successfulEvents, + ); + await publishDigitalLetterUnsuccessfulEvents( + eventPublisher, + logger, + cloudEventSource, + unsuccessfulEvents, + ); logger.info({ description: 'Processed SQS Event.', diff --git a/lambdas/nhsapp-status-handler/src/container.ts b/lambdas/nhsapp-status-handler/src/container.ts index 132a9f1d2..495cf6eb6 100644 --- a/lambdas/nhsapp-status-handler/src/container.ts +++ b/lambdas/nhsapp-status-handler/src/container.ts @@ -10,8 +10,12 @@ import { TtlRepository } from 'infra/ttl-repository'; import { TtlActions } from 'app/ttl-actions'; export const createContainer = () => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn, ttlTableName } = - loadConfig(); + const { + cloudEventSource, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + ttlTableName, + } = loadConfig(); const requestTtlRepository = new TtlRepository( ttlTableName, @@ -32,6 +36,7 @@ export const createContainer = () => { ttlActions, eventPublisher, logger, + cloudEventSource, }; }; diff --git a/lambdas/nhsapp-status-handler/src/infra/config.ts b/lambdas/nhsapp-status-handler/src/infra/config.ts index d2122e00e..9ae5d85bc 100644 --- a/lambdas/nhsapp-status-handler/src/infra/config.ts +++ b/lambdas/nhsapp-status-handler/src/infra/config.ts @@ -1,12 +1,13 @@ import { defaultConfigReader } from 'utils'; -export type TtlCreateConfig = { +export type NhsappStatusHandlerConfig = { ttlTableName: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + cloudEventSource: string; }; -export function loadConfig(): TtlCreateConfig { +export function loadConfig(): NhsappStatusHandlerConfig { return { ttlTableName: defaultConfigReader.getValue('TTL_TABLE_NAME'), eventPublisherEventBusArn: defaultConfigReader.getValue( @@ -15,5 +16,6 @@ export function loadConfig(): TtlCreateConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + cloudEventSource: defaultConfigReader.getValue('CLOUD_EVENT_SOURCE'), }; } diff --git a/utils/utils/src/types/channel-status-failed-event.ts b/utils/utils/src/types/channel-status-failed-event.ts new file mode 100644 index 000000000..a673d4fa2 --- /dev/null +++ b/utils/utils/src/types/channel-status-failed-event.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const $ChannelStatusFailedEvent = z.object({ + data: z.object({ + messageReference: z.string(), + clientId: z.string(), + channelStatus: z.literal('failed'), + supplierStatus: z.literal('rejected'), + channelFailureReasonCode: z.string(), + channelFailedReason: z.string(), + }), +}); + +export type ChannelStatusFailedEvent = z.infer< + typeof $ChannelStatusFailedEvent +>; diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index c5272f269..db4ba746d 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,4 +1,6 @@ +export * from './channel-status-failed-event'; export * from './channel-status-published-event'; +export * from './message-status-failed-event'; export * from './pdm-types'; export * from './sender'; export * from './supplier-api-letter-event'; diff --git a/utils/utils/src/types/message-status-failed-event.ts b/utils/utils/src/types/message-status-failed-event.ts new file mode 100644 index 000000000..06584a2be --- /dev/null +++ b/utils/utils/src/types/message-status-failed-event.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const $MessageStatusFailedEvent = z.object({ + data: z.object({ + messageReference: z.string(), + clientId: z.string(), + messageStatus: z.literal('failed'), + messageStatusDescription: z.string(), + messageFailureReasonCode: z.string(), + channels: z.array(z.unknown()).length(0), + }), +}); + +export type MessageStatusFailedEvent = z.infer< + typeof $MessageStatusFailedEvent +>; From 512a138e576239a130933882b02ad5cb73dcfb6b Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Fri, 15 May 2026 09:56:27 +0100 Subject: [PATCH 03/17] Update contract testing version --- package-lock.json | 10 +++++----- scripts/package.json | 2 +- tests/pact-tests/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 283f080fa..1b9e8c762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9895,9 +9895,9 @@ } }, "node_modules/@nhsdigital/nhs-notify-event-schemas-status-published": { - "version": "1.0.2", - "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-status-published/1.0.2/e652e2c14b9f748ded5569176b5ad1092633201e", - "integrity": "sha512-E0UH9iX0nwZwowNe6u3pNcycmP0IfoKm1FA1N1kmONXXExeZ1nV6L2nP7FnwRjYlHcNegvmi8bCj17RufsRQCg==", + "version": "1.1.0", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-status-published/1.1.0/2ad98e6baa0400ed509eccf26a6e579a2ff3ff5f", + "integrity": "sha512-x1Hp/0Wv44WwGA8nNmFZXuh77kcq6/IiAvDzslNl7TWAurQW0285wtHjGdJxD3lQ6JnXhYFMfFwShgmW/UjWhQ==", "license": "MIT", "dependencies": { "zod": "^4.0.17" @@ -26731,7 +26731,7 @@ "version": "0.0.1", "dependencies": { "@aws-sdk/client-eventbridge": "^3.914.0", - "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", "csv-parse": "^6.1.0", "tsx": "^4.20.6", @@ -27752,7 +27752,7 @@ "name": "nhs-notify-digital-letters-pact-tests", "version": "0.0.1", "devDependencies": { - "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", "@pact-foundation/pact": "^16.3.0", "@types/jest": "^29.5.14", diff --git a/scripts/package.json b/scripts/package.json index cfd38d34a..06b65fd00 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@aws-sdk/client-eventbridge": "^3.914.0", - "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", "csv-parse": "^6.1.0", "tsx": "^4.20.6", diff --git a/tests/pact-tests/package.json b/tests/pact-tests/package.json index 02bf54c41..cef06603e 100644 --- a/tests/pact-tests/package.json +++ b/tests/pact-tests/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-status-published": "^1.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.18", "@pact-foundation/pact": "^16.3.0", "@types/jest": "^29.5.14", From 763887046e76d721aca361d5bf9c3f1b4f81a46f Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 10:02:40 +0100 Subject: [PATCH 04/17] CCM-17639: Fix unit test --- lambdas/nhsapp-status-handler/package.json | 1 + package-lock.json | 28 ++++++++-------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/lambdas/nhsapp-status-handler/package.json b/lambdas/nhsapp-status-handler/package.json index a9827e47f..8dab02021 100644 --- a/lambdas/nhsapp-status-handler/package.json +++ b/lambdas/nhsapp-status-handler/package.json @@ -3,6 +3,7 @@ "@aws-sdk/client-dynamodb": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.908.0", "digital-letters-events": "^0.0.1", + "jose": "^5.10.0", "utils": "^0.0.1" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 1b9e8c762..ac4c4ce18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -970,15 +970,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "lambdas/key-generation/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "lambdas/key-generation/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -1312,6 +1303,7 @@ "@aws-sdk/client-dynamodb": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.908.0", "digital-letters-events": "^0.0.1", + "jose": "^5.10.0", "utils": "^0.0.1" }, "devDependencies": { @@ -21102,6 +21094,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -28943,15 +28944,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "utils/utils/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "utils/utils/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", From ec041cef71bf6ef34c650f91c7108077cf9f24cd Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 10:31:10 +0100 Subject: [PATCH 05/17] Revert "CCM-17639: Fix unit test" This reverts commit 763887046e76d721aca361d5bf9c3f1b4f81a46f. --- lambdas/nhsapp-status-handler/package.json | 1 - package-lock.json | 28 ++++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lambdas/nhsapp-status-handler/package.json b/lambdas/nhsapp-status-handler/package.json index 8dab02021..a9827e47f 100644 --- a/lambdas/nhsapp-status-handler/package.json +++ b/lambdas/nhsapp-status-handler/package.json @@ -3,7 +3,6 @@ "@aws-sdk/client-dynamodb": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.908.0", "digital-letters-events": "^0.0.1", - "jose": "^5.10.0", "utils": "^0.0.1" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index ac4c4ce18..1b9e8c762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -970,6 +970,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "lambdas/key-generation/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "lambdas/key-generation/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -1303,7 +1312,6 @@ "@aws-sdk/client-dynamodb": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.908.0", "digital-letters-events": "^0.0.1", - "jose": "^5.10.0", "utils": "^0.0.1" }, "devDependencies": { @@ -21094,15 +21102,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -28944,6 +28943,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "utils/utils/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "utils/utils/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", From b9824358000ca5287a9700bd8b57dee7d5229a30 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 10:50:34 +0100 Subject: [PATCH 06/17] CCM-17639: Fix coverage --- .../__tests__/apis/sqs-trigger-lambda.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 7a7e32124..41e7cc5ed 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -166,6 +166,27 @@ describe('createHandler', () => { ); }); + it('handles unknown event type when detail.data is missing', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify({ detail: {} }), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing sqs record', + messageReference: 'not present', + }), + ); + }); + it('handles ttlActions.markWithdrawn failure', async () => { ttlActions.markWithdrawn.mockResolvedValue({ result: 'failed' }); const event: SQSEvent = { From 7a70be5ea4fc08b79e00b4574295505e4fca8bbe Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 11:44:16 +0100 Subject: [PATCH 07/17] CCM-17639: PR Comments --- .../__tests__/apis/sqs-trigger-lambda.test.ts | 82 +++++++++++-- .../src/__tests__/data.ts | 2 - .../src/apis/sqs-trigger-lambda.ts | 114 +++++++++++++----- .../src/types/channel-status-failed-event.ts | 1 - .../src/types/message-status-failed-event.ts | 1 - 5 files changed, 154 insertions(+), 46 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 41e7cc5ed..02ee77b53 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -103,7 +103,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error parsing sqs record', messageReference: 'not present', @@ -131,7 +131,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error parsing sqs record', messageReference, @@ -158,7 +158,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error parsing sqs record', messageReference: 'not present', @@ -179,7 +179,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: 'Error parsing sqs record', messageReference: 'not present', @@ -214,7 +214,7 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ description: expect.stringContaining( 'Error during SQS trigger handler', @@ -438,11 +438,12 @@ describe('createHandler', () => { [ expect.objectContaining({ specversion: '1.0', + subject: 'customer/sender1/recipient/ref1', type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', source: cloudEventSource, data: { - messageReference: channelStatusFailedEvent.data.messageReference, - senderId: channelStatusFailedEvent.data.clientId, + messageReference: 'ref1', + senderId: 'sender1', reasonCode: channelStatusFailedEvent.data.channelFailureReasonCode, reasonText: channelStatusFailedEvent.data.channelFailedReason, @@ -454,7 +455,8 @@ describe('createHandler', () => { expect(logger.info).toHaveBeenCalledWith({ description: 'Channel status failed event received', - messageReference: channelStatusFailedEvent.data.messageReference, + messageReference: 'ref1', + senderId: 'sender1', channelFailureReasonCode: channelStatusFailedEvent.data.channelFailureReasonCode, }); @@ -538,11 +540,12 @@ describe('createHandler', () => { [ expect.objectContaining({ specversion: '1.0', + subject: 'customer/sender1/recipient/ref1', type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', source: cloudEventSource, data: { - messageReference: messageStatusFailedEvent.data.messageReference, - senderId: messageStatusFailedEvent.data.clientId, + messageReference: 'ref1', + senderId: 'sender1', reasonCode: messageStatusFailedEvent.data.messageFailureReasonCode, reasonText: @@ -555,7 +558,8 @@ describe('createHandler', () => { expect(logger.info).toHaveBeenCalledWith({ description: 'Message status failed event received', - messageReference: messageStatusFailedEvent.data.messageReference, + messageReference: 'ref1', + senderId: 'sender1', messageFailureReasonCode: messageStatusFailedEvent.data.messageFailureReasonCode, }); @@ -592,6 +596,34 @@ describe('createHandler', () => { eventCount: 1, }); }); + + it('fails the record when a message status event has an invalid Notify message reference', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify({ + detail: { + ...messageStatusFailedEvent, + data: { + ...messageStatusFailedEvent.data, + messageReference: 'invalid-reference', + }, + }, + }), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith({ + description: 'Invalid Notify message reference', + messageReference: 'invalid-reference', + }); + }); }); describe('mixed event types', () => { @@ -635,5 +667,33 @@ describe('createHandler', () => { success: 2, }); }); + + it('fails the record when a failed status event has an invalid Notify message reference', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify({ + detail: { + ...channelStatusFailedEvent, + data: { + ...channelStatusFailedEvent.data, + messageReference: 'invalid-reference', + }, + }, + }), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith({ + description: 'Invalid Notify message reference', + messageReference: 'invalid-reference', + }); + }); }); }); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/data.ts b/lambdas/nhsapp-status-handler/src/__tests__/data.ts index e5fdd6571..7172d22c8 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/data.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/data.ts @@ -40,7 +40,6 @@ export const nhsAppStatusEvent: ChannelStatusPublishedEvent = { export const channelStatusFailedEvent: ChannelStatusFailedEvent = { data: { messageReference: 'sender1_ref1', - clientId: 'sender1', channelStatus: 'failed', supplierStatus: 'rejected', channelFailureReasonCode: 'NO_NHS_APP_ACCOUNT', @@ -52,7 +51,6 @@ export const channelStatusFailedEvent: ChannelStatusFailedEvent = { export const messageStatusFailedEvent: MessageStatusFailedEvent = { data: { messageReference: 'sender1_ref1', - clientId: 'sender1', messageStatus: 'failed', messageStatusDescription: 'PDS enrichment failed for the patient', messageFailureReasonCode: 'PDS_ENRICHMENT_FAILED', diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts index 8b5fc987e..3861c5283 100644 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts @@ -34,10 +34,7 @@ type UnsuccessfulEventData = NonNullable< ProcessingResult['unsuccessfulEventData'] >; -type RecordProcessingResult = ProcessingResult & { - shouldFail: boolean; - messageId: string; -}; +type RecordProcessingResult = ProcessingResult & { messageId: string }; interface CreateHandlerDependencies { ttlActions: TtlActions; @@ -64,6 +61,50 @@ const parseFailedMessageReference = (sqsEventDetail: unknown): string => { : 'not present'; }; +const parseNotifyMessageReference = ( + notifyMessageReference: string, +): Pick | undefined => { + const separatorIndex = notifyMessageReference.indexOf('_'); + + if ( + separatorIndex <= 0 || + separatorIndex === notifyMessageReference.length - 1 + ) { + return undefined; + } + + return { + senderId: notifyMessageReference.slice(0, separatorIndex), + messageReference: notifyMessageReference.slice(separatorIndex + 1), + }; +}; + +const buildUnsuccessfulEventData = ( + logger: Logger, + notifyMessageReference: string, + reasonCode: string, + reasonText: string, +): UnsuccessfulEventData | undefined => { + const messageReferenceParts = parseNotifyMessageReference( + notifyMessageReference, + ); + + if (!messageReferenceParts) { + logger.error({ + description: 'Invalid message reference', + messageReference: notifyMessageReference, + }); + + return undefined; + } + + return { + ...messageReferenceParts, + reasonCode, + reasonText, + }; +}; + const processRecord = async ( body: string, messageId: string, @@ -81,10 +122,10 @@ const processRecord = async ( const result = await ttlActions.markWithdrawn(channelStatusResult.data); if (result.result === 'failed') { - return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + return { outcome: { result: 'failed' }, messageId }; } - return { outcome: result, shouldFail: false, messageId }; + return { outcome: result, messageId }; } const channelFailedResult = @@ -92,22 +133,27 @@ const processRecord = async ( if (channelFailedResult.success) { const { data } = channelFailedResult; + const unsuccessfulEventData = buildUnsuccessfulEventData( + logger, + data.data.messageReference, + data.data.channelFailureReasonCode, + data.data.channelFailedReason, + ); + + if (!unsuccessfulEventData) { + return { outcome: { result: 'failed' }, messageId }; + } logger.info({ description: 'Channel status failed event received', - messageReference: data.data.messageReference, + messageReference: unsuccessfulEventData.messageReference, + senderId: unsuccessfulEventData.senderId, channelFailureReasonCode: data.data.channelFailureReasonCode, }); return { outcome: { result: 'success', ttlItem: undefined }, - unsuccessfulEventData: { - messageReference: data.data.messageReference, - senderId: data.data.clientId, - reasonCode: data.data.channelFailureReasonCode, - reasonText: data.data.channelFailedReason, - }, - shouldFail: false, + unsuccessfulEventData, messageId, }; } @@ -117,40 +163,45 @@ const processRecord = async ( if (messageFailedResult.success) { const { data } = messageFailedResult; + const unsuccessfulEventData = buildUnsuccessfulEventData( + logger, + data.data.messageReference, + data.data.messageFailureReasonCode, + data.data.messageStatusDescription, + ); + + if (!unsuccessfulEventData) { + return { outcome: { result: 'failed' }, messageId }; + } logger.info({ description: 'Message status failed event received', - messageReference: data.data.messageReference, + messageReference: unsuccessfulEventData.messageReference, + senderId: unsuccessfulEventData.senderId, messageFailureReasonCode: data.data.messageFailureReasonCode, }); return { outcome: { result: 'success', ttlItem: undefined }, - unsuccessfulEventData: { - messageReference: data.data.messageReference, - senderId: data.data.clientId, - reasonCode: data.data.messageFailureReasonCode, - reasonText: data.data.messageStatusDescription, - }, - shouldFail: false, + unsuccessfulEventData, messageId, }; } - logger.warn({ + logger.error({ err: channelStatusResult.error, messageReference: parseFailedMessageReference(sqsEventDetail), description: 'Error parsing sqs record', }); - return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + return { outcome: { result: 'failed' }, messageId }; } catch (error) { - logger.warn({ + logger.error({ err: error, description: 'Error during SQS trigger handler', }); - return { outcome: { result: 'failed' }, shouldFail: true, messageId }; + return { outcome: { result: 'failed' }, messageId }; } }; @@ -169,12 +220,11 @@ const collectProcessingResults = ( for (const result of results) { if (result.status === 'fulfilled') { - const { messageId, outcome, shouldFail, unsuccessfulEventData } = - result.value; + const { messageId, outcome, unsuccessfulEventData } = result.value; processed[outcome.result] += 1; - if (shouldFail) { + if (outcome.result === 'failed') { batchItemFailures.push({ itemIdentifier: messageId }); } @@ -202,6 +252,7 @@ const collectProcessingResults = ( const publishDigitalLetterReadEvents = async ( eventPublisher: EventPublisher, logger: Logger, + cloudEventSource: string, successfulEvents: MESHInboxMessageDownloaded[], ): Promise => { if (successfulEvents.length === 0) { @@ -218,7 +269,7 @@ const publishDigitalLetterReadEvents = async ( type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', - source: event.source.replace(/\/mesh$/, '/queue'), + source: cloudEventSource, data: { messageReference: event.data.messageReference, senderId: event.data.senderId, @@ -260,7 +311,7 @@ const publishDigitalLetterUnsuccessfulEvents = async ( specversion: '1.0' as const, id: randomUUID(), source: cloudEventSource, - subject: `message/${eventData.messageReference}`, + subject: `customer/${eventData.senderId}/recipient/${eventData.messageReference}`, type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1' as const, plane: 'data' as const, time: new Date().toISOString(), @@ -322,6 +373,7 @@ export const createHandler = ({ await publishDigitalLetterReadEvents( eventPublisher, logger, + cloudEventSource, successfulEvents, ); await publishDigitalLetterUnsuccessfulEvents( diff --git a/utils/utils/src/types/channel-status-failed-event.ts b/utils/utils/src/types/channel-status-failed-event.ts index a673d4fa2..115fdf941 100644 --- a/utils/utils/src/types/channel-status-failed-event.ts +++ b/utils/utils/src/types/channel-status-failed-event.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; export const $ChannelStatusFailedEvent = z.object({ data: z.object({ messageReference: z.string(), - clientId: z.string(), channelStatus: z.literal('failed'), supplierStatus: z.literal('rejected'), channelFailureReasonCode: z.string(), diff --git a/utils/utils/src/types/message-status-failed-event.ts b/utils/utils/src/types/message-status-failed-event.ts index 06584a2be..a798fad49 100644 --- a/utils/utils/src/types/message-status-failed-event.ts +++ b/utils/utils/src/types/message-status-failed-event.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; export const $MessageStatusFailedEvent = z.object({ data: z.object({ messageReference: z.string(), - clientId: z.string(), messageStatus: z.literal('failed'), messageStatusDescription: z.string(), messageFailureReasonCode: z.string(), From 68ca94b259ff440b658de4d08181182faebe95f8 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 12:12:26 +0100 Subject: [PATCH 08/17] CCM-17639: Fix unit test --- .../src/__tests__/apis/sqs-trigger-lambda.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 02ee77b53..d404ae525 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -620,7 +620,7 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith({ - description: 'Invalid Notify message reference', + description: 'Invalid message reference', messageReference: 'invalid-reference', }); }); @@ -691,7 +691,7 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith({ - description: 'Invalid Notify message reference', + description: 'Invalid message reference', messageReference: 'invalid-reference', }); }); From f9fcc154e48358a6a381f770ecc12bbc492604b9 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Mon, 18 May 2026 15:16:06 +0100 Subject: [PATCH 09/17] CCM-17639: Update cloud source --- .../dl/module_lambda_nhsapp_status_handler.tf | 1 - .../__tests__/apis/sqs-trigger-lambda.test.ts | 42 ++++++++++++++++--- .../src/__tests__/container.test.ts | 2 - .../src/__tests__/data.ts | 2 + .../src/apis/sqs-trigger-lambda.ts | 27 ++++++------ .../nhsapp-status-handler/src/container.ts | 9 +--- .../nhsapp-status-handler/src/infra/config.ts | 2 - .../src/types/channel-status-failed-event.ts | 1 + .../src/types/message-status-failed-event.ts | 1 + 9 files changed, 55 insertions(+), 32 deletions(-) diff --git a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf index 97ed9facd..a4e1d6b26 100644 --- a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf @@ -38,7 +38,6 @@ module "nhsapp_status_handler" { "TTL_TABLE_NAME" = aws_dynamodb_table.ttl.name "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url - "CLOUD_EVENT_SOURCE" = "/nhs/england/notify/${var.environment}/${var.group}/digitalletters/queue" } } diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index d404ae525..f459bf6cd 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -48,9 +48,6 @@ describe('createHandler', () => { }, }; - const cloudEventSource = - '/nhs/england/notify/production/primary/digitalletters/queue'; - beforeEach(() => { ttlActions = { markWithdrawn: jest.fn() }; eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; @@ -59,7 +56,6 @@ describe('createHandler', () => { ttlActions, eventPublisher, logger, - cloudEventSource, }); }); @@ -440,7 +436,8 @@ describe('createHandler', () => { specversion: '1.0', subject: 'customer/sender1/recipient/ref1', type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', - source: cloudEventSource, + source: + '/nhs/england/notify/production/primary/digitalletters/queue', data: { messageReference: 'ref1', senderId: 'sender1', @@ -542,7 +539,8 @@ describe('createHandler', () => { specversion: '1.0', subject: 'customer/sender1/recipient/ref1', type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', - source: cloudEventSource, + source: + '/nhs/england/notify/production/primary/digitalletters/queue', data: { messageReference: 'ref1', senderId: 'sender1', @@ -597,6 +595,34 @@ describe('createHandler', () => { }); }); + it('fails the record when a message status event has no source', async () => { + const event: SQSEvent = { + Records: [ + { + body: JSON.stringify({ + detail: { + data: { + ...messageStatusFailedEvent.data, + }, + }, + }), + messageId: 'msg1', + }, + ], + } as any; + + const res = await handler(event); + + expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg1' }]); + expect(eventPublisher.sendEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing sqs record', + messageReference: messageStatusFailedEvent.data.messageReference, + }), + ); + }); + it('fails the record when a message status event has an invalid Notify message reference', async () => { const event: SQSEvent = { Records: [ @@ -604,6 +630,8 @@ describe('createHandler', () => { body: JSON.stringify({ detail: { ...messageStatusFailedEvent, + source: + '/nhs/england/notify/production/primary/digitalletters/messaging', data: { ...messageStatusFailedEvent.data, messageReference: 'invalid-reference', @@ -675,6 +703,8 @@ describe('createHandler', () => { body: JSON.stringify({ detail: { ...channelStatusFailedEvent, + source: + '/nhs/england/notify/production/primary/digitalletters/messaging', data: { ...channelStatusFailedEvent.data, messageReference: 'invalid-reference', diff --git a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts index d7c9b7799..d52ce0228 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts @@ -5,8 +5,6 @@ jest.mock('infra/config', () => ({ eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', ttlTableName: 'test-table', - cloudEventSource: - '/nhs/england/notify/production/primary/digitalletters/queue', })), })); diff --git a/lambdas/nhsapp-status-handler/src/__tests__/data.ts b/lambdas/nhsapp-status-handler/src/__tests__/data.ts index 7172d22c8..15c2ac05d 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/data.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/data.ts @@ -38,6 +38,7 @@ export const nhsAppStatusEvent: ChannelStatusPublishedEvent = { }; export const channelStatusFailedEvent: ChannelStatusFailedEvent = { + source: '/nhs/england/notify/production/primary/digitalletters/messaging', data: { messageReference: 'sender1_ref1', channelStatus: 'failed', @@ -49,6 +50,7 @@ export const channelStatusFailedEvent: ChannelStatusFailedEvent = { }; export const messageStatusFailedEvent: MessageStatusFailedEvent = { + source: '/nhs/england/notify/production/primary/digitalletters/messaging', data: { messageReference: 'sender1_ref1', messageStatus: 'failed', diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts index 3861c5283..fcd1b3a18 100644 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts @@ -23,6 +23,7 @@ import { interface ProcessingResult { outcome: TtlActionOutcome; unsuccessfulEventData?: { + source: string; messageReference: string; senderId: string; reasonCode: string; @@ -40,7 +41,6 @@ interface CreateHandlerDependencies { ttlActions: TtlActions; eventPublisher: EventPublisher; logger: Logger; - cloudEventSource: string; } const isRecord = (value: unknown): value is Record => @@ -81,6 +81,7 @@ const parseNotifyMessageReference = ( const buildUnsuccessfulEventData = ( logger: Logger, + source: string, notifyMessageReference: string, reasonCode: string, reasonText: string, @@ -99,6 +100,7 @@ const buildUnsuccessfulEventData = ( } return { + source, ...messageReferenceParts, reasonCode, reasonText, @@ -135,6 +137,7 @@ const processRecord = async ( const { data } = channelFailedResult; const unsuccessfulEventData = buildUnsuccessfulEventData( logger, + data.source.replace(/\/[^/]+$/, '/queue'), data.data.messageReference, data.data.channelFailureReasonCode, data.data.channelFailedReason, @@ -165,6 +168,7 @@ const processRecord = async ( const { data } = messageFailedResult; const unsuccessfulEventData = buildUnsuccessfulEventData( logger, + data.source.replace(/\/[^/]+$/, '/queue'), data.data.messageReference, data.data.messageFailureReasonCode, data.data.messageStatusDescription, @@ -252,7 +256,6 @@ const collectProcessingResults = ( const publishDigitalLetterReadEvents = async ( eventPublisher: EventPublisher, logger: Logger, - cloudEventSource: string, successfulEvents: MESHInboxMessageDownloaded[], ): Promise => { if (successfulEvents.length === 0) { @@ -269,7 +272,7 @@ const publishDigitalLetterReadEvents = async ( type: 'uk.nhs.notify.digital.letters.queue.digital.letter.read.v1', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-digital-letter-read-data.schema.json', - source: cloudEventSource, + source: event.source.replace(/\/[^/]+$/, '/queue'), data: { messageReference: event.data.messageReference, senderId: event.data.senderId, @@ -297,7 +300,6 @@ const publishDigitalLetterReadEvents = async ( const publishDigitalLetterUnsuccessfulEvents = async ( eventPublisher: EventPublisher, logger: Logger, - cloudEventSource: string, unsuccessfulEvents: UnsuccessfulEventData[], ): Promise => { if (unsuccessfulEvents.length === 0) { @@ -307,11 +309,11 @@ const publishDigitalLetterUnsuccessfulEvents = async ( try { const failedEvents = await eventPublisher.sendEvents( - unsuccessfulEvents.map((eventData) => ({ + unsuccessfulEvents.map((event) => ({ specversion: '1.0' as const, id: randomUUID(), - source: cloudEventSource, - subject: `customer/${eventData.senderId}/recipient/${eventData.messageReference}`, + source: event.source, + subject: `customer/${event.senderId}/recipient/${event.messageReference}`, type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1' as const, plane: 'data' as const, time: new Date().toISOString(), @@ -324,10 +326,10 @@ const publishDigitalLetterUnsuccessfulEvents = async ( '00-00000000000000000000000000000000-0000000000000000-00', severitynumber: 3, data: { - messageReference: eventData.messageReference, - senderId: eventData.senderId, - reasonCode: eventData.reasonCode, - reasonText: eventData.reasonText, + messageReference: event.messageReference, + senderId: event.senderId, + reasonCode: event.reasonCode, + reasonText: event.reasonText, }, })), validateDigitalLetterUnsuccessful, @@ -351,7 +353,6 @@ const publishDigitalLetterUnsuccessfulEvents = async ( }; export const createHandler = ({ - cloudEventSource, eventPublisher, logger, ttlActions, @@ -373,13 +374,11 @@ export const createHandler = ({ await publishDigitalLetterReadEvents( eventPublisher, logger, - cloudEventSource, successfulEvents, ); await publishDigitalLetterUnsuccessfulEvents( eventPublisher, logger, - cloudEventSource, unsuccessfulEvents, ); diff --git a/lambdas/nhsapp-status-handler/src/container.ts b/lambdas/nhsapp-status-handler/src/container.ts index 495cf6eb6..132a9f1d2 100644 --- a/lambdas/nhsapp-status-handler/src/container.ts +++ b/lambdas/nhsapp-status-handler/src/container.ts @@ -10,12 +10,8 @@ import { TtlRepository } from 'infra/ttl-repository'; import { TtlActions } from 'app/ttl-actions'; export const createContainer = () => { - const { - cloudEventSource, - eventPublisherDlqUrl, - eventPublisherEventBusArn, - ttlTableName, - } = loadConfig(); + const { eventPublisherDlqUrl, eventPublisherEventBusArn, ttlTableName } = + loadConfig(); const requestTtlRepository = new TtlRepository( ttlTableName, @@ -36,7 +32,6 @@ export const createContainer = () => { ttlActions, eventPublisher, logger, - cloudEventSource, }; }; diff --git a/lambdas/nhsapp-status-handler/src/infra/config.ts b/lambdas/nhsapp-status-handler/src/infra/config.ts index 9ae5d85bc..f4a66421e 100644 --- a/lambdas/nhsapp-status-handler/src/infra/config.ts +++ b/lambdas/nhsapp-status-handler/src/infra/config.ts @@ -4,7 +4,6 @@ export type NhsappStatusHandlerConfig = { ttlTableName: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; - cloudEventSource: string; }; export function loadConfig(): NhsappStatusHandlerConfig { @@ -16,6 +15,5 @@ export function loadConfig(): NhsappStatusHandlerConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), - cloudEventSource: defaultConfigReader.getValue('CLOUD_EVENT_SOURCE'), }; } diff --git a/utils/utils/src/types/channel-status-failed-event.ts b/utils/utils/src/types/channel-status-failed-event.ts index 115fdf941..70b1146ab 100644 --- a/utils/utils/src/types/channel-status-failed-event.ts +++ b/utils/utils/src/types/channel-status-failed-event.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; export const $ChannelStatusFailedEvent = z.object({ + source: z.string(), data: z.object({ messageReference: z.string(), channelStatus: z.literal('failed'), diff --git a/utils/utils/src/types/message-status-failed-event.ts b/utils/utils/src/types/message-status-failed-event.ts index a798fad49..a70e33a25 100644 --- a/utils/utils/src/types/message-status-failed-event.ts +++ b/utils/utils/src/types/message-status-failed-event.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; export const $MessageStatusFailedEvent = z.object({ + source: z.string(), data: z.object({ messageReference: z.string(), messageStatus: z.literal('failed'), From c39584ba162b2c4e2e6d3d97b5b48d49a33f54df Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Tue, 19 May 2026 09:16:31 +0100 Subject: [PATCH 10/17] CCM-17639: Added playwright tests --- .../nhsapp-status-handler.component.spec.ts | 89 ++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts index eb9f65a60..5b5d6378c 100644 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { ENV, + EVENT_BUS_LOG_GROUP_NAME, NHSAPP_STATUS_HANDLER_DLQ_NAME, NHSAPP_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME, } from 'constants/backend-constants'; @@ -14,6 +15,9 @@ import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - NHSApp Status Handler', () => { + const statusEventSource = + '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging'; + test.beforeAll(async () => { await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME); }); @@ -68,7 +72,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + source: statusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: concatedReference, @@ -137,7 +141,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { }; const channelStatusPublishedEvent = { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + source: statusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: concatedReference, @@ -197,7 +201,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + source: statusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: concatedReference, @@ -229,7 +233,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging', + source: statusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: concatedReference, @@ -262,4 +266,81 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { ), ]); }); + + test('should publish digital.letter.unsuccessful event for channel status failed', async () => { + const senderId = `sender-${uuidv4()}`; + const messageReference = uuidv4(); + const notifyMessageReference = `${senderId}_${messageReference}`; + + await eventPublisher.sendEvents( + [ + { + source: statusEventSource, + type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', + data: { + messageReference: notifyMessageReference, + channelStatus: 'failed', + supplierStatus: 'rejected', + channelFailureReasonCode: 'CFR_SUPE_0001', + channelFailedReason: 'Failed reason: Not registered with NHS App', + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1"', + String.raw`$.details.event_detail = "*\"messageReference\":\"${messageReference}\"*"`, + String.raw`$.details.event_detail = "*\"senderId\":\"${senderId}\"*"`, + String.raw`$.details.event_detail = "*\"reasonCode\":\"CFR_SUPE_0001\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + }); + + test('should publish digital.letter.unsuccessful event for message status failed', async () => { + const senderId = `sender-${uuidv4()}`; + const messageReference = uuidv4(); + const notifyMessageReference = `${senderId}_${messageReference}`; + + await eventPublisher.sendEvents( + [ + { + source: statusEventSource, + type: 'uk.nhs.notify.message.status.PUBLISHED.v1', + data: { + messageReference: notifyMessageReference, + messageStatus: 'failed', + messageStatusDescription: + 'Failed reason: No reachable communication channels', + messageFailureReasonCode: 'MFR_CFGV_0002', + channels: [], + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1"', + String.raw`$.details.event_detail = "*\"messageReference\":\"${messageReference}\"*"`, + String.raw`$.details.event_detail = "*\"senderId\":\"${senderId}\"*"`, + String.raw`$.details.event_detail = "*\"reasonCode\":\"MFR_CFGV_0002\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + }); }); From 9318bfe581a949ace6ca35365b55c0f5466bab6b Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Tue, 19 May 2026 11:19:48 +0100 Subject: [PATCH 11/17] CCM-17639: Fix tests --- .../nhsapp-status-handler.component.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts index 5b5d6378c..f5d5efe94 100644 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts @@ -17,6 +17,8 @@ import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - NHSApp Status Handler', () => { const statusEventSource = '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging'; + const unsuccessfulStatusEventSource = + '/nhs/england/notify/development/dev-1/digitalletters/messaging'; test.beforeAll(async () => { await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME); @@ -275,7 +277,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: statusEventSource, + source: unsuccessfulStatusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: notifyMessageReference, @@ -313,7 +315,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: statusEventSource, + source: unsuccessfulStatusEventSource, type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageReference: notifyMessageReference, From 8ba17ee6287fcacfb56d75dc1082f1b5b53ec7e6 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 09:19:22 +0100 Subject: [PATCH 12/17] CCM-17639: Update sources --- .../src/apis/sqs-trigger-lambda.ts | 15 +++++++++++---- .../nhsapp-status-handler.component.spec.ts | 6 ++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts index fcd1b3a18..7acc2808d 100644 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts @@ -79,6 +79,13 @@ const parseNotifyMessageReference = ( }; }; +const deriveDigitalLettersQueueSource = (source: string): string => { + return source + .replace(/\/data-plane\/messaging$/, '/digitalletters/queue') + .replace(/\/digitalletters\/messaging$/, '/digitalletters/queue') + .replace(/\/[^/]+$/, '/queue'); +}; + const buildUnsuccessfulEventData = ( logger: Logger, source: string, @@ -137,7 +144,7 @@ const processRecord = async ( const { data } = channelFailedResult; const unsuccessfulEventData = buildUnsuccessfulEventData( logger, - data.source.replace(/\/[^/]+$/, '/queue'), + deriveDigitalLettersQueueSource(data.source), data.data.messageReference, data.data.channelFailureReasonCode, data.data.channelFailedReason, @@ -168,7 +175,7 @@ const processRecord = async ( const { data } = messageFailedResult; const unsuccessfulEventData = buildUnsuccessfulEventData( logger, - data.source.replace(/\/[^/]+$/, '/queue'), + deriveDigitalLettersQueueSource(data.source), data.data.messageReference, data.data.messageFailureReasonCode, data.data.messageStatusDescription, @@ -328,8 +335,8 @@ const publishDigitalLetterUnsuccessfulEvents = async ( data: { messageReference: event.messageReference, senderId: event.senderId, - reasonCode: event.reasonCode, - reasonText: event.reasonText, + failureCode: event.reasonCode, + failureReason: event.reasonText, }, })), validateDigitalLetterUnsuccessful, diff --git a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts index f5d5efe94..5b5d6378c 100644 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts @@ -17,8 +17,6 @@ import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - NHSApp Status Handler', () => { const statusEventSource = '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging'; - const unsuccessfulStatusEventSource = - '/nhs/england/notify/development/dev-1/digitalletters/messaging'; test.beforeAll(async () => { await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME); @@ -277,7 +275,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: unsuccessfulStatusEventSource, + source: statusEventSource, type: 'uk.nhs.notify.channel.status.PUBLISHED.v1', data: { messageReference: notifyMessageReference, @@ -315,7 +313,7 @@ test.describe('Digital Letters - NHSApp Status Handler', () => { await eventPublisher.sendEvents( [ { - source: unsuccessfulStatusEventSource, + source: statusEventSource, type: 'uk.nhs.notify.message.status.PUBLISHED.v1', data: { messageReference: notifyMessageReference, From 19f5f3f605ffff5fdceb0375415e3132dbc236a3 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 09:31:06 +0100 Subject: [PATCH 13/17] CCM-17639: Fix tests --- .../src/__tests__/apis/sqs-trigger-lambda.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index f459bf6cd..6f99a68ac 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -441,9 +441,9 @@ describe('createHandler', () => { data: { messageReference: 'ref1', senderId: 'sender1', - reasonCode: + failureCode: channelStatusFailedEvent.data.channelFailureReasonCode, - reasonText: channelStatusFailedEvent.data.channelFailedReason, + failureReason: channelStatusFailedEvent.data.channelFailedReason, }, }), ], @@ -544,9 +544,9 @@ describe('createHandler', () => { data: { messageReference: 'ref1', senderId: 'sender1', - reasonCode: + failureCode: messageStatusFailedEvent.data.messageFailureReasonCode, - reasonText: + failureReason: messageStatusFailedEvent.data.messageStatusDescription, }, }), From ebe388115eb423637ef83b172bdaa9f43b48d898 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 09:35:11 +0100 Subject: [PATCH 14/17] CCM-17639: Update supplier api --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b9e8c762..5f108e1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9904,9 +9904,9 @@ } }, "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-api": { - "version": "1.0.18", - "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-api/1.0.18/e81996aca0271b686b521dec9ec33a8735b351b5", - "integrity": "sha512-+PJ4XSWYZ+hZ2jhbvt2Wpi0QzPAYug2bnl6NrdEQmcURVhAh5JxJkuQ7OayHXFS2A7hiEF+4nJnS6B0ZLiWSgw==", + "version": "1.0.19", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-api/1.0.19/46a72901639161b3dafa0d47af70d1e9ec8f4686", + "integrity": "sha512-DqS0GmQ64jfLuKDerUuUFMZEdTjsEtpHROoMeXsjc54zELH4RLmZTok/gJozDXqSpn0twWPLu8QlP7bss7tmXQ==", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", From b49bb722413cdf4e6da2846c9614891ac539f405 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 10:04:15 +0100 Subject: [PATCH 15/17] CCM-17639: Fix typecheck --- lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts index 7acc2808d..a7f08fac4 100644 --- a/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/nhsapp-status-handler/src/apis/sqs-trigger-lambda.ts @@ -335,8 +335,8 @@ const publishDigitalLetterUnsuccessfulEvents = async ( data: { messageReference: event.messageReference, senderId: event.senderId, - failureCode: event.reasonCode, - failureReason: event.reasonText, + reasonCode: event.reasonCode, + reasonText: event.reasonText, }, })), validateDigitalLetterUnsuccessful, From e2e9d909e2c11dbf391f35cfffdfc9f1be5087ec Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 10:12:52 +0100 Subject: [PATCH 16/17] CCM-17639: Fix unit tests --- .../src/__tests__/apis/sqs-trigger-lambda.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts index 6f99a68ac..f459bf6cd 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -441,9 +441,9 @@ describe('createHandler', () => { data: { messageReference: 'ref1', senderId: 'sender1', - failureCode: + reasonCode: channelStatusFailedEvent.data.channelFailureReasonCode, - failureReason: channelStatusFailedEvent.data.channelFailedReason, + reasonText: channelStatusFailedEvent.data.channelFailedReason, }, }), ], @@ -544,9 +544,9 @@ describe('createHandler', () => { data: { messageReference: 'ref1', senderId: 'sender1', - failureCode: + reasonCode: messageStatusFailedEvent.data.messageFailureReasonCode, - failureReason: + reasonText: messageStatusFailedEvent.data.messageStatusDescription, }, }), From 74520e9e81a830b0d6f64a7bcb3ea6bdd9119077 Mon Sep 17 00:00:00 2001 From: "scott.fullerton1" Date: Thu, 21 May 2026 14:56:53 +0100 Subject: [PATCH 17/17] CCM-17639: Update test source --- .../nhsapp-status-handler.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts index 5b5d6378c..48bf9de33 100644 --- a/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/nhsapp-status-handler.component.spec.ts @@ -16,7 +16,7 @@ import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - NHSApp Status Handler', () => { const statusEventSource = - '/nhs/england/notify/comms-mgr-dev/dev/data-plane/messaging'; + '/nhs/england/notify/development/primary/data-plane/messaging'; test.beforeAll(async () => { await purgeQueue(NHSAPP_STATUS_HANDLER_DLQ_NAME);