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/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_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 f5d3584e4..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 @@ -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'; @@ -46,7 +52,11 @@ describe('createHandler', () => { 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, + }); }); it('processes a valid SQS event and returns success', async () => { @@ -81,7 +91,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; @@ -89,9 +99,9 @@ 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 parsing sqs record'), + description: 'Error parsing sqs record', messageReference: 'not present', }), ); @@ -103,7 +113,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: [ @@ -117,9 +127,9 @@ 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 parsing sqs record'), + description: 'Error parsing sqs record', messageReference, }), ); @@ -131,6 +141,48 @@ 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.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error parsing sqs record', + messageReference: 'not present', + }), + ); + }); + + 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.error).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 = { @@ -158,7 +210,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', @@ -358,4 +410,320 @@ 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', + subject: 'customer/sender1/recipient/ref1', + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + source: + '/nhs/england/notify/production/primary/digitalletters/queue', + data: { + messageReference: 'ref1', + senderId: 'sender1', + reasonCode: + channelStatusFailedEvent.data.channelFailureReasonCode, + reasonText: channelStatusFailedEvent.data.channelFailedReason, + }, + }), + ], + validateDigitalLetterUnsuccessful, + ); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Channel status failed event received', + messageReference: 'ref1', + senderId: 'sender1', + 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', + subject: 'customer/sender1/recipient/ref1', + type: 'uk.nhs.notify.digital.letters.queue.digital.letter.unsuccessful.v1', + source: + '/nhs/england/notify/production/primary/digitalletters/queue', + data: { + messageReference: 'ref1', + senderId: 'sender1', + reasonCode: + messageStatusFailedEvent.data.messageFailureReasonCode, + reasonText: + messageStatusFailedEvent.data.messageStatusDescription, + }, + }), + ], + validateDigitalLetterUnsuccessful, + ); + + expect(logger.info).toHaveBeenCalledWith({ + description: 'Message status failed event received', + messageReference: 'ref1', + senderId: 'sender1', + 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, + }); + }); + + 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: [ + { + body: JSON.stringify({ + detail: { + ...messageStatusFailedEvent, + source: + '/nhs/england/notify/production/primary/digitalletters/messaging', + 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 message reference', + messageReference: 'invalid-reference', + }); + }); + }); + + 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, + }); + }); + + 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, + source: + '/nhs/england/notify/production/primary/digitalletters/messaging', + 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 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 6235d612b..15c2ac05d 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 = { + source: '/nhs/england/notify/production/primary/digitalletters/messaging', + data: { + messageReference: 'sender1_ref1', + 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 = { + source: '/nhs/england/notify/production/primary/digitalletters/messaging', + data: { + messageReference: 'sender1_ref1', + 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..a7f08fac4 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,389 @@ 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?: { + source: string; + messageReference: string; + senderId: string; + reasonCode: string; + reasonText: string; + }; } +type UnsuccessfulEventData = NonNullable< + ProcessingResult['unsuccessfulEventData'] +>; + +type RecordProcessingResult = ProcessingResult & { messageId: string }; + interface CreateHandlerDependencies { ttlActions: TtlActions; eventPublisher: EventPublisher; logger: Logger; } -export const createHandler = ({ - 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 isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; - const results = await Promise.allSettled(promises); +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 parseNotifyMessageReference = ( + notifyMessageReference: string, +): Pick | undefined => { + const separatorIndex = notifyMessageReference.indexOf('_'); + + if ( + separatorIndex <= 0 || + separatorIndex === notifyMessageReference.length - 1 + ) { + return undefined; + } - const processed: Record = - { - retrieved: results.length, - success: 0, - failed: 0, + return { + senderId: notifyMessageReference.slice(0, separatorIndex), + messageReference: notifyMessageReference.slice(separatorIndex + 1), + }; +}; + +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, + 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 { + source, + ...messageReferenceParts, + reasonCode, + reasonText, + }; +}; + +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' }, messageId }; + } + + return { outcome: result, messageId }; + } + + const channelFailedResult = + $ChannelStatusFailedEvent.safeParse(sqsEventDetail); + + if (channelFailedResult.success) { + const { data } = channelFailedResult; + const unsuccessfulEventData = buildUnsuccessfulEventData( + logger, + deriveDigitalLettersQueueSource(data.source), + 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: unsuccessfulEventData.messageReference, + senderId: unsuccessfulEventData.senderId, + channelFailureReasonCode: data.data.channelFailureReasonCode, + }); + + return { + outcome: { result: 'success', ttlItem: undefined }, + unsuccessfulEventData, + messageId, }; + } - const successfulEvents: MESHInboxMessageDownloaded[] = []; + const messageFailedResult = + $MessageStatusFailedEvent.safeParse(sqsEventDetail); - for (const result of results) { - if (result.status === 'fulfilled') { - const { outcome } = result.value; - processed[outcome.result] += 1; + if (messageFailedResult.success) { + const { data } = messageFailedResult; + const unsuccessfulEventData = buildUnsuccessfulEventData( + logger, + deriveDigitalLettersQueueSource(data.source), + data.data.messageReference, + data.data.messageFailureReasonCode, + data.data.messageStatusDescription, + ); - if (outcome.result === 'success' && outcome.ttlItem) { - successfulEvents.push(outcome.ttlItem.event); - } - } else { - logger.warn({ err: result.reason }); - processed.failed += 1; + if (!unsuccessfulEventData) { + return { outcome: { result: 'failed' }, messageId }; } + + logger.info({ + description: 'Message status failed event received', + messageReference: unsuccessfulEventData.messageReference, + senderId: unsuccessfulEventData.senderId, + messageFailureReasonCode: data.data.messageFailureReasonCode, + }); + + return { + outcome: { result: 'success', ttlItem: undefined }, + unsuccessfulEventData, + messageId, + }; } - 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, - }); + logger.error({ + err: channelStatusResult.error, + messageReference: parseFailedMessageReference(sqsEventDetail), + description: 'Error parsing sqs record', + }); + + return { outcome: { result: 'failed' }, messageId }; + } catch (error) { + logger.error({ + err: error, + description: 'Error during SQS trigger handler', + }); + + return { outcome: { result: 'failed' }, 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, unsuccessfulEventData } = result.value; + + processed[outcome.result] += 1; + + if (outcome.result === 'failed') { + 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(/\/[^/]+$/, '/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, + unsuccessfulEvents: UnsuccessfulEventData[], +): Promise => { + if (unsuccessfulEvents.length === 0) { + return; + } + + try { + const failedEvents = + await eventPublisher.sendEvents( + unsuccessfulEvents.map((event) => ({ + specversion: '1.0' as const, + id: randomUUID(), + 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(), + 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: event.messageReference, + senderId: event.senderId, + reasonCode: event.reasonCode, + reasonText: event.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 = ({ + eventPublisher, + logger, + ttlActions, +}: CreateHandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const promises = sqsEvent.Records.map(({ body, messageId }) => + processRecord(body, messageId, logger, ttlActions), + ); + + const results = await Promise.allSettled(promises); + + const { + batchItemFailures, + processed, + successfulEvents, + unsuccessfulEvents, + } = collectProcessingResults(results, logger); + + await publishDigitalLetterReadEvents( + eventPublisher, + logger, + successfulEvents, + ); + await publishDigitalLetterUnsuccessfulEvents( + eventPublisher, + logger, + unsuccessfulEvents, + ); logger.info({ description: 'Processed SQS Event.', diff --git a/lambdas/nhsapp-status-handler/src/infra/config.ts b/lambdas/nhsapp-status-handler/src/infra/config.ts index 3f81612a2..060de7e7a 100644 --- a/lambdas/nhsapp-status-handler/src/infra/config.ts +++ b/lambdas/nhsapp-status-handler/src/infra/config.ts @@ -1,6 +1,6 @@ import { defaultConfigReader } from 'utils'; -export type TtlCreateConfig = { +export type NhsappStatusHandlerConfig = { environment: string; ttlTableName: string; eventPublisherEventBusArn: string; @@ -8,7 +8,7 @@ export type TtlCreateConfig = { dlMetricsNamespace: string; }; -export function loadConfig(): TtlCreateConfig { +export function loadConfig(): NhsappStatusHandlerConfig { return { environment: defaultConfigReader.getValue('ENVIRONMENT'), ttlTableName: defaultConfigReader.getValue('TTL_TABLE_NAME'), diff --git a/package-lock.json b/package-lock.json index c9c8ebcfe..e5f263e90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9995,9 +9995,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", 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 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..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 @@ -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/development/primary/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); + }); }); 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..70b1146ab --- /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({ + source: z.string(), + data: z.object({ + messageReference: 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..a70e33a25 --- /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({ + source: z.string(), + data: z.object({ + messageReference: 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 +>;