diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md
index ab2bdf4e4..26aeafacd 100644
--- a/infrastructure/terraform/components/dl/README.md
+++ b/infrastructure/terraform/components/dl/README.md
@@ -11,10 +11,11 @@ No requirements.
|------|-------------|------|---------|:--------:|
| [apim\_auth\_token\_schedule](#input\_apim\_auth\_token\_schedule) | Schedule to renew the APIM auth token | `string` | `"rate(9 minutes)"` | no |
| [apim\_auth\_token\_url](#input\_apim\_auth\_token\_url) | URL to generate an APIM auth token | `string` | `"https://int.api.service.nhs.uk/oauth2/token"` | no |
-| [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to Notify and PDM | `string` | `"https://int.api.service.nhs.uk"` | no |
+| [apim\_base\_url](#input\_apim\_base\_url) | The URL used to send requests to PDM | `string` | `"https://int.api.service.nhs.uk"` | no |
| [apim\_keygen\_schedule](#input\_apim\_keygen\_schedule) | Schedule to refresh key pairs if necessary | `string` | `"cron(0 14 * * ? *)"` | no |
| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no |
+| [core\_notify\_url](#input\_core\_notify\_url) | The URL used to send requests to Notify | `string` | `"https://sandbox.api.service.nhs.uk"` | no |
| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
| [enable\_dynamodb\_delete\_protection](#input\_enable\_dynamodb\_delete\_protection) | Enable DynamoDB Delete Protection on all Tables | `bool` | `true` | no |
| [enable\_mock\_mesh](#input\_enable\_mock\_mesh) | Enable mock mesh access (dev only). Grants lambda permission to read mock-mesh prefix in non-pii bucket. | `bool` | `false` | no |
@@ -40,6 +41,7 @@ No requirements.
| Name | Source | Version |
|------|--------|---------|
+| [core\_notifier](#module\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-kms.zip | n/a |
| [lambda\_apim\_key\_generation](#module\_lambda\_apim\_key\_generation) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [lambda\_lambda\_apim\_refresh\_token](#module\_lambda\_lambda\_apim\_refresh\_token) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
@@ -53,6 +55,7 @@ No requirements.
| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
| [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
+| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf
new file mode 100644
index 000000000..1e4aff1fb
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_available.tf
@@ -0,0 +1,18 @@
+resource "aws_cloudwatch_event_rule" "pdm_resource_available" {
+ name = "${local.csi}-pdm-resource-available"
+ description = "PDM resource available event rule"
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+ event_pattern = jsonencode({
+ "detail" : {
+ "type" : [
+ "uk.nhs.notify.digital.letters.pdm.resource.available.v1"
+ ],
+ }
+ })
+}
+
+resource "aws_cloudwatch_event_target" "pdm_resource_available_core_notifier" {
+ rule = aws_cloudwatch_event_rule.pdm_resource_available.name
+ arn = module.sqs_core_notifier.sqs_queue_arn
+ event_bus_name = aws_cloudwatch_event_bus.main.name
+}
diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf
new file mode 100644
index 000000000..54e7a8a75
--- /dev/null
+++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_core_notifier_lambda.tf
@@ -0,0 +1,10 @@
+resource "aws_lambda_event_source_mapping" "core_notifier_lambda" {
+ event_source_arn = module.sqs_core_notifier.sqs_queue_arn
+ function_name = module.core_notifier.function_arn
+ batch_size = var.queue_batch_size
+ maximum_batching_window_in_seconds = var.queue_batch_window_seconds
+
+ function_response_types = [
+ "ReportBatchItemFailures"
+ ]
+}
diff --git a/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf
new file mode 100644
index 000000000..c39fbba9d
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf
@@ -0,0 +1,119 @@
+module "core_notifier" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "core-notifier"
+ description = "A function to send messages to core Notify when a PDM resource is available"
+
+ aws_account_id = var.aws_account_id
+ component = local.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = module.kms.key_arn
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.core_notifier_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "core-notifier-lambda/dist"
+ function_include_common = true
+ handler_function_name = "handler"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 60
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ log_destination_arn = local.log_destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = {
+ "APIM_BASE_URL" = var.core_notify_url
+ "APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME" = local.apim_access_token_ssm_parameter_name
+ "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ "ENVIRONMENT" = var.environment
+ }
+}
+
+data "aws_iam_policy_document" "core_notifier_lambda" {
+ statement {
+ sid = "AllowSSMParam"
+ effect = "Allow"
+
+ actions = [
+ "ssm:GetParameter",
+ "ssm:GetParameters",
+ "ssm:GetParametersByPath"
+ ]
+
+ resources = [
+ "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/apim/*",
+ "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/senders/*"
+ ]
+ }
+
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsUploadToCoreNotifierQueue"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:GetQueueUrl",
+ ]
+
+ resources = [
+ module.sqs_core_notifier.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "PutEvents"
+ effect = "Allow"
+
+ actions = [
+ "events:PutEvents",
+ ]
+
+ resources = [
+ aws_cloudwatch_event_bus.main.arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsDLQ"
+ effect = "Allow"
+
+ actions = [
+ "sqs:SendMessage",
+ "sqs:SendMessageBatch",
+ ]
+
+ resources = [
+ module.sqs_event_publisher_errors.sqs_queue_arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf
new file mode 100644
index 000000000..6aab47018
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf
@@ -0,0 +1,44 @@
+module "sqs_core_notifier" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = local.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "core-notifier"
+
+ sqs_kms_key_arn = module.kms.key_arn
+
+ visibility_timeout_seconds = 60
+
+ create_dlq = true
+
+ sqs_policy_overload = data.aws_iam_policy_document.sqs_inbound_event.json
+}
+
+data "aws_iam_policy_document" "sqs_inbound_event" {
+ statement {
+ sid = "AllowEventBridgeToSendMessage"
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["events.amazonaws.com"]
+ }
+
+ actions = [
+ "sqs:SendMessage"
+ ]
+
+ resources = [
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-core-notifier-queue"
+ ]
+
+ condition {
+ test = "ArnLike"
+ variable = "aws:SourceArn"
+ values = [ aws_cloudwatch_event_rule.pdm_resource_available.arn ]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf
index ca1109ee0..be79f0153 100644
--- a/infrastructure/terraform/components/dl/variables.tf
+++ b/infrastructure/terraform/components/dl/variables.tf
@@ -136,10 +136,17 @@ variable "pdm_use_non_mock_token" {
variable "apim_base_url" {
type = string
- description = "The URL used to send requests to Notify and PDM"
+ description = "The URL used to send requests to PDM"
default = "https://int.api.service.nhs.uk"
}
+
+variable "core_notify_url" {
+ type = string
+ description = "The URL used to send requests to Notify"
+ default = "https://sandbox.api.service.nhs.uk"
+}
+
variable "apim_auth_token_url" {
type = string
description = "URL to generate an APIM auth token"
diff --git a/lambdas/core-notifier-lambda/jest.config.ts b/lambdas/core-notifier-lambda/jest.config.ts
new file mode 100644
index 000000000..c02601aec
--- /dev/null
+++ b/lambdas/core-notifier-lambda/jest.config.ts
@@ -0,0 +1,5 @@
+import { baseJestConfig } from '../../jest.config.base';
+
+const config = baseJestConfig;
+
+export default config;
diff --git a/lambdas/core-notifier-lambda/package.json b/lambdas/core-notifier-lambda/package.json
new file mode 100644
index 000000000..f3d1a6950
--- /dev/null
+++ b/lambdas/core-notifier-lambda/package.json
@@ -0,0 +1,27 @@
+{
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "axios": "^1.13.2",
+ "digital-letters-events": "^0.0.1",
+ "sender-management": "^0.0.1",
+ "utils": "^0.0.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ },
+ "name": "nhs-notify-digital-core-notifier-lambda",
+ "private": true,
+ "scripts": {
+ "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test:unit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "version": "0.0.1"
+}
diff --git a/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts
new file mode 100644
index 000000000..ceb3a6985
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts
@@ -0,0 +1,297 @@
+import type { SQSEvent, SQSRecord } from 'aws-lambda';
+import { mock } from 'jest-mock-extended';
+import { EventPublisher, Logger, Sender } from 'utils';
+import { NotifyMessageProcessor } from 'app/notify-message-processor';
+import { ISenderManagement } from 'sender-management';
+import { SqsHandlerDependencies, createHandler } from 'apis/sqs-handler';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event';
+import { RequestNotifyError } from 'domain/request-notify-error';
+import { validPdmEvent, validSender } from '__tests__/constants';
+import {
+ MessageRequestRejected,
+ MessageRequestSkipped,
+ MessageRequestSubmitted,
+} from 'digital-letters-events';
+
+jest.mock('app/parse-sqs-message');
+
+const mockLogger = mock();
+const mockNotifyMessageProcessor = mock();
+const mockSenderManagement = mock();
+const mockEventPublisher = mock();
+const mockParseSqsRecord = jest.mocked(parseSqsRecord);
+
+const createSqsRecord = (messageId: string): SQSRecord => ({
+ messageId,
+ receiptHandle: 'receipt-handle',
+ body: JSON.stringify({
+ detail: validPdmEvent,
+ }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+});
+
+const createSqsEvent = (recordCount: number): SQSEvent => ({
+ Records: Array.from({ length: recordCount }, (_, i) =>
+ createSqsRecord(`message-id-${i + 1}`),
+ ),
+});
+
+describe('createHandler', () => {
+ const dependencies: SqsHandlerDependencies = {
+ logger: mockLogger,
+ notifyMessageProcessor: mockNotifyMessageProcessor,
+ senderManagement: mockSenderManagement,
+ eventPublisher: mockEventPublisher,
+ };
+
+ const senderId = 'sender-123';
+ const messageReference = 'msg-ref-123';
+ const notifyId = 'notify-id-123';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when processing a single successful SQS record', () => {
+ it('processes the message and returns no batch item failures', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValueOnce(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(validSender);
+ mockNotifyMessageProcessor.process.mockResolvedValueOnce(notifyId);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Received SQS Event of 1 record(s)',
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: '1 of 1 records processed successfully',
+ });
+ expect(mockParseSqsRecord).toHaveBeenCalledWith(
+ sqsEvent.Records[0],
+ mockLogger,
+ );
+ expect(mockSenderManagement.getSender).toHaveBeenCalledWith({
+ senderId,
+ });
+ expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(1);
+ expect(
+ mockEventPublisher.sendEvents,
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips the message and publishes a skipped event', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+ const senderWithoutRouting: Sender = {
+ ...validSender,
+ routingConfigId: undefined,
+ };
+
+ mockParseSqsRecord.mockReturnValueOnce(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(senderWithoutRouting);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(mockNotifyMessageProcessor.process).not.toHaveBeenCalled();
+ expect(
+ mockEventPublisher.sendEvents,
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ mockEventPublisher.sendEvents,
+ ).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ data: expect.objectContaining({
+ senderId: validSender.senderId,
+ }),
+ }),
+ ]),
+ expect.any(Function),
+ );
+ });
+
+ it('throws an error when sender is not found', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValueOnce(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(null);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: sqsEvent.Records[0].messageId }],
+ });
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: 'Failed processing message',
+ messageId: sqsEvent.Records[0].messageId,
+ senderId: validSender.senderId,
+ }),
+ );
+ });
+ });
+
+ describe('when processing multiple SQS records', () => {
+ it('processes all records successfully', async () => {
+ const sqsEvent = createSqsEvent(3);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValue(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(validSender);
+ mockNotifyMessageProcessor.process.mockResolvedValue(notifyId);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Received SQS Event of 3 record(s)',
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: '3 of 3 records processed successfully',
+ });
+ expect(mockParseSqsRecord).toHaveBeenCalledTimes(3);
+ expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(3);
+ });
+
+ it('returns only failed message IDs', async () => {
+ const sqsEvent = createSqsEvent(3);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord
+ .mockReturnValueOnce(validPdmEvent)
+ .mockImplementationOnce(() => {
+ throw new Error('Parse error');
+ })
+ .mockReturnValueOnce(validPdmEvent);
+
+ mockSenderManagement.getSender.mockResolvedValue(validSender);
+ mockNotifyMessageProcessor.process.mockResolvedValue(notifyId);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: 'message-id-2' }],
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: '2 of 3 records processed successfully',
+ });
+ });
+ });
+
+ describe('when parseSqsRecord throws InvalidPdmResourceAvailableEvent', () => {
+ it('marks the message as failed for retry', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+ const { messageId } = sqsEvent.Records[0];
+
+ mockParseSqsRecord.mockImplementationOnce(() => {
+ throw new InvalidPdmResourceAvailableEvent(messageId);
+ });
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: messageId }],
+ });
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ error: 'Unable to parse PDMResourceAvailable event from SQS message',
+ description: 'Failed processing message',
+ messageId,
+ senderId: undefined,
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: '0 of 1 records processed successfully',
+ });
+ });
+ });
+
+ describe('when processing throws error', () => {
+ it('marks the message as failed as is not a RequestNotifyError', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+ const { messageId } = sqsEvent.Records[0];
+ const error = new Error('Validation failed');
+
+ mockParseSqsRecord.mockReturnValueOnce(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(validSender);
+ mockNotifyMessageProcessor.process.mockRejectedValueOnce(error);
+
+ const result = await handler(sqsEvent);
+
+ // Since RequestNotifyError doesn't have messageReference property,
+ // it falls through to the else branch and is treated as a transient error
+ expect(result).toEqual({
+ batchItemFailures: [{ itemIdentifier: messageId }],
+ });
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ error: error.message,
+ description: 'Failed processing message',
+ messageId,
+ senderId: validSender.senderId,
+ });
+ expect(
+ mockEventPublisher.sendEvents,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('publishes rejected event when error has messageReference property', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+ const { messageId } = sqsEvent.Records[0];
+ const errorCode = 'VALIDATION_ERROR';
+ const correlationId = 'corr-123';
+ const error = new RequestNotifyError(
+ new Error('Validation failed'),
+ correlationId,
+ errorCode,
+ );
+ // Add messageReference property dynamically to trigger the terminal error path
+ (error as any).messageReference = messageReference;
+
+ mockParseSqsRecord.mockReturnValueOnce(validPdmEvent);
+ mockSenderManagement.getSender.mockResolvedValue(validSender);
+ mockNotifyMessageProcessor.process.mockRejectedValueOnce(error);
+
+ const result = await handler(sqsEvent);
+
+ // With messageReference property, it's treated as terminal error and not retried
+ expect(result).toEqual({ batchItemFailures: [] });
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ error: error.message,
+ description: 'Failed processing message',
+ messageId,
+ senderId: validSender.senderId,
+ });
+ expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1);
+ expect(mockEventPublisher.sendEvents).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ data: expect.objectContaining({
+ senderId,
+ messageReference,
+ failureCode: errorCode,
+ }),
+ }),
+ ]),
+ expect.any(Function),
+ );
+ });
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts
new file mode 100644
index 000000000..59af51fd2
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/app/notify-api-client.test.ts
@@ -0,0 +1,267 @@
+import { randomUUID } from 'node:crypto';
+import { mock } from 'jest-mock-extended';
+import axios, {
+ type AxiosInstance,
+ type AxiosResponse,
+ type AxiosStatic,
+} from 'axios';
+import { RetryErrorConditionFn, conditionalRetry as _retry } from 'utils';
+import type { Logger } from 'utils';
+import { mockRequest1, mockResponse } from '__tests__/constants';
+import { IAccessTokenRepository, NotifyClient } from 'app/notify-api-client';
+import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
+
+jest.mock('utils');
+jest.mock('node:crypto');
+jest.mock('axios', () => {
+ const original: AxiosStatic = jest.requireActual('axios');
+
+ return { ...original, create: jest.fn() };
+});
+
+const mockRetry = async (
+ fn: (attempt: number) => Promise,
+ isRetryable: RetryErrorConditionFn,
+ _: unknown,
+ attempt = 1,
+): Promise => {
+ try {
+ return await fn(attempt);
+ } catch (error) {
+ if (isRetryable(error)) {
+ return mockRetry(fn, isRetryable, attempt + 1);
+ }
+ throw error;
+ }
+};
+
+jest.mocked(_retry).mockImplementation(mockRetry);
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+});
+
+const apimBaseUrl = 'https://api.service.nhs.uk';
+
+function setup() {
+ const logger = mock();
+
+ const accessTokenRepository = mock();
+ accessTokenRepository.getAccessToken.mockResolvedValue('fake-access-token');
+
+ const axiosInstance = mock();
+ (axios.create as jest.Mock).mockReturnValueOnce(axiosInstance);
+
+ (randomUUID as jest.Mock).mockReturnValue('not-random-uuid');
+
+ const mocks = { accessTokenRepository, axiosInstance, logger };
+
+ const client = new NotifyClient(apimBaseUrl, accessTokenRepository, logger);
+
+ return { client, mocks };
+}
+
+describe('constructor', () => {
+ it('creates a new axios instance with correct config', () => {
+ setup();
+
+ expect(axios.create).toHaveBeenCalledWith({
+ baseURL: apimBaseUrl,
+ });
+ });
+});
+
+describe('sendRequest', () => {
+ it('successfully sends a request', async () => {
+ const { client, mocks } = setup();
+
+ const response = {
+ status: 200,
+ data: mockResponse,
+ };
+
+ mocks.axiosInstance.post.mockResolvedValueOnce(response);
+
+ const actual = await client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ );
+
+ expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1);
+ expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1);
+ expect(mocks.axiosInstance.post).toHaveBeenCalledWith(
+ '/comms/v1/messages',
+ {
+ data: mockRequest1.data,
+ },
+ {
+ headers: {
+ Authorization: 'Bearer fake-access-token',
+ 'Content-Type': 'application/json',
+ 'X-Correlation-ID': 'request-item-id_request-item-plan-id',
+ },
+ },
+ );
+
+ expect(actual).toBe(response.data);
+ });
+
+ it('successfully sends a request without auhtorisation header', async () => {
+ const { client, mocks } = setup();
+
+ mocks.accessTokenRepository.getAccessToken.mockResolvedValue('');
+
+ const response = {
+ status: 200,
+ data: mockResponse,
+ };
+
+ mocks.axiosInstance.post.mockResolvedValueOnce(response);
+
+ const actual = await client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ );
+
+ expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1);
+ expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1);
+ expect(mocks.axiosInstance.post).toHaveBeenCalledWith(
+ '/comms/v1/messages',
+ {
+ data: mockRequest1.data,
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Correlation-ID': 'request-item-id_request-item-plan-id',
+ },
+ },
+ );
+
+ expect(actual).toBe(response.data);
+ });
+
+ it('retries on 429 status code errors and re-fetches access token each time', async () => {
+ const { client, mocks } = setup();
+
+ const error = {
+ isAxiosError: true,
+ response: { status: 429 },
+ };
+
+ const response = mock({
+ status: 200,
+ data: { type: 'Message' },
+ });
+
+ mocks.axiosInstance.post
+ .mockRejectedValueOnce(error)
+ .mockResolvedValueOnce(response);
+
+ await client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ );
+
+ expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(2);
+ expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([400, 401, 403, 404])(
+ 'rejects %d status code errors immediately',
+ async (status) => {
+ const { client, mocks } = setup();
+
+ const error = {
+ isAxiosError: true,
+ response: {
+ status,
+ data: {
+ errors: [
+ {
+ id: 'rrt-1931948104716186917-c-geu2-10664-3111479-3.0',
+ code: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
+ links: {
+ about:
+ 'https://digital.nhs.uk/developer/api-catalogue/nhs-notify',
+ },
+ status,
+ title: 'Templates missing',
+ detail:
+ 'The templates required to use the routing plan were not found.',
+ source: {
+ pointer: '/data/attributes/routingPlanId',
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ mocks.axiosInstance.post.mockRejectedValue(error);
+
+ await expect(
+ client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ ),
+ ).rejects.toMatchObject({
+ errorCode: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
+ correlationId: 'request-item-id_request-item-plan-id',
+ });
+ },
+ );
+
+ it('throws the appropriate error when a 422 status is returned', async () => {
+ const { client, mocks } = setup();
+
+ const error = {
+ isAxiosError: true,
+ response: { status: 422 },
+ };
+
+ mocks.axiosInstance.post.mockRejectedValue(error);
+
+ await expect(
+ client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ ),
+ ).rejects.toBeInstanceOf(RequestAlreadyReceivedError);
+ });
+
+ it('rejects non-axios errors immediately', async () => {
+ const { client, mocks } = setup();
+
+ const error = new Error('wahh');
+
+ mocks.axiosInstance.post.mockRejectedValue(error);
+
+ await expect(
+ client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ ),
+ ).rejects.toEqual(error);
+ });
+
+ it('rejects if unable to get the access token', async () => {
+ const { client, mocks } = setup();
+
+ const error = new Error('wahh');
+
+ mocks.accessTokenRepository.getAccessToken.mockRejectedValue(error);
+
+ await expect(
+ client.sendRequest(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ ),
+ ).rejects.toEqual(error);
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts
new file mode 100644
index 000000000..71a46c030
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/app/notify-message-processor.test.ts
@@ -0,0 +1,87 @@
+import { mock } from 'jest-mock-extended';
+import { logger } from 'utils';
+import { NotifyMessageProcessor } from 'app/notify-message-processor';
+import { mockRequest1, mockResponse } from '__tests__/constants';
+import { NotifyClient } from 'app/notify-api-client';
+import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
+
+jest.mock('utils');
+
+const mockClient = mock();
+
+const mockLogger = jest.mocked(logger);
+const senderId = 'test-sender-id';
+
+const notifyMessageProcessor = new NotifyMessageProcessor({
+ nhsNotifyClient: mockClient,
+ logger: mockLogger,
+});
+
+describe('NotifyMessageProcessor', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('completes when the API client succeeds', async () => {
+ mockClient.sendRequest.mockResolvedValueOnce(mockResponse);
+
+ expect(
+ await notifyMessageProcessor.process(mockRequest1, senderId),
+ ).toEqual(mockResponse.data.id);
+
+ expect(mockClient.sendRequest).toHaveBeenCalledTimes(1);
+ expect(mockClient.sendRequest).toHaveBeenCalledWith(
+ mockRequest1,
+ mockRequest1.data.attributes.messageReference,
+ );
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Sending request to Notify API',
+ messageReference: mockRequest1.data.attributes.messageReference,
+ senderId,
+ });
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Successfully processed request and sent to Notify',
+ messageReference: mockRequest1.data.attributes.messageReference,
+ messageItemId: mockResponse.data.id,
+ senderId,
+ });
+ });
+
+ it('re-throws when the API client fails', async () => {
+ const errorMessage = 'API failure';
+ const err = new Error(errorMessage);
+ mockClient.sendRequest.mockRejectedValue(err);
+
+ await expect(
+ notifyMessageProcessor.process(mockRequest1, senderId),
+ ).rejects.toThrow(err);
+
+ expect(mockLogger.error).toHaveBeenCalledWith({
+ description: 'Failed sending request to Notify API',
+ messageReference: mockRequest1.data.attributes.messageReference,
+ senderId,
+ error: errorMessage,
+ });
+ });
+
+ it('re-throw when a RequestAlreadyReceivedError is thrown by the API client', async () => {
+ const { messageReference } = mockRequest1.data.attributes;
+ const err = new RequestAlreadyReceivedError(
+ new Error('Request was already received!'),
+ messageReference,
+ );
+ mockClient.sendRequest.mockRejectedValue(err);
+
+ await expect(
+ notifyMessageProcessor.process(mockRequest1, senderId),
+ ).rejects.toThrow(err);
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Request has already been received by Notify',
+ messageReference,
+ senderId,
+ });
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts b/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts
new file mode 100644
index 000000000..ad6257ff8
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/app/parse-sqs-message.test.ts
@@ -0,0 +1,101 @@
+import { SQSRecord } from 'aws-lambda';
+import { mock } from 'jest-mock-extended';
+import { Logger } from 'utils';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event';
+import { validPdmEvent } from '__tests__/constants';
+
+const mockLogger = mock();
+
+describe('parseSqsRecord', () => {
+ const messageId = 'test-message-id-123';
+
+ const createSqsRecord = (detailContents: any): SQSRecord => ({
+ messageId,
+ receiptHandle: 'receipt-handle',
+ body: JSON.stringify({ detail: detailContents }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when SQS record contains a valid PDMResourceAvailable event', () => {
+ it('parses and returns the PDMResourceAvailable event', () => {
+ const sqsRecord = createSqsRecord(validPdmEvent);
+
+ const result = parseSqsRecord(sqsRecord, mockLogger);
+
+ expect(result).toEqual(validPdmEvent);
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Parsing SQS Record',
+ messageId,
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Parsed valid PDMResourceAvailable Event',
+ messageId,
+ messageReference: validPdmEvent.data.messageReference,
+ senderId: validPdmEvent.data.senderId,
+ resourceId: validPdmEvent.data.resourceId,
+ });
+ });
+ });
+
+ describe('when SQS record contains an invalid PDMResourceAvailable event', () => {
+ it('logs error and throws InvalidPdmResourceAvailableEvent', () => {
+ const invalidEvent = { ...validPdmEvent, data: {} };
+ const sqsRecord = createSqsRecord(invalidEvent);
+
+ expect(() => parseSqsRecord(sqsRecord, mockLogger)).toThrow(
+ InvalidPdmResourceAvailableEvent,
+ );
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description:
+ 'The SQS message does not contain a valid PDMResourceAvailable event',
+ messageId,
+ error: expect.any(Array),
+ }),
+ );
+ });
+ });
+
+ describe('when SQS record body is malformed JSON', () => {
+ it('throws a JSON parse error', () => {
+ const sqsRecord: SQSRecord = {
+ messageId,
+ receiptHandle: 'receipt-handle',
+ body: 'not valid json{',
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ };
+
+ expect(() => parseSqsRecord(sqsRecord, mockLogger)).toThrow(SyntaxError);
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Parsing SQS Record',
+ messageId,
+ });
+ });
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/constants.ts b/lambdas/core-notifier-lambda/src/__tests__/constants.ts
new file mode 100644
index 000000000..e81c888a0
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/constants.ts
@@ -0,0 +1,89 @@
+import type {
+ SingleMessageRequest,
+ SingleMessageResponse,
+} from 'domain/request';
+import { Sender } from 'utils';
+
+import { PDMResourceAvailable } from 'digital-letters-events';
+
+export const mockRequest1: SingleMessageRequest = {
+ data: {
+ type: 'Message',
+ attributes: {
+ routingPlanId: 'routing-plan-id',
+ messageReference: 'request-item-id_request-item-plan-id',
+ billingReference:
+ 'test-client-id_test-campaign-id_test-billing-reference',
+ recipient: {
+ nhsNumber: '9999999786',
+ },
+ originator: {
+ odsCode: 'A12345',
+ },
+ personalisation: {
+ digitalLetterURL:
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=12345',
+ },
+ },
+ },
+};
+
+export const mockRequest2 = {
+ ...mockRequest1,
+ recipient: {
+ nhsNumber: '9999999788',
+ },
+};
+
+export const mockResponse: SingleMessageResponse = {
+ data: {
+ type: 'Message',
+ id: '30XcAOfwjq59r72AQTjxL4V7Heg',
+ attributes: {
+ messageReference: '6e6aca3f-9e83-4c37-8bc0-b2bb0b2c7e0d',
+ messageStatus: 'created',
+ timestamps: {
+ created: '2025-07-29T08:20:13.408Z',
+ },
+ routingPlan: {
+ id: 'fc4f8c6b-1547-4216-9237-c7027c97ae60',
+ version: '4HMorh_sMD7kr98GL43u0KR3qZNik4dW',
+ createdDate: '2025-07-23T10:34:13.000Z',
+ name: 'SMS nudge V1.0',
+ },
+ },
+ links: {
+ self: 'https://some.url/comms/v1/messages/30XcAOfwjq59r72AQTjxL4V7Heg',
+ },
+ },
+};
+
+export const validPdmEvent: PDMResourceAvailable = {
+ id: 'event-id-123',
+ source:
+ '/nhs/england/notify/development/dev-12345/data-plane/digitalletters/pdm',
+ specversion: '1.0',
+ type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1',
+ time: '2025-12-15T10:00:00Z',
+ datacontenttype: 'application/json',
+ subject: 'message-subject-123',
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ recordedtime: '2025-12-15T10:00:00Z',
+ severitynumber: 2,
+ data: {
+ senderId: 'sender-123',
+ messageReference: 'msg-ref-123',
+ resourceId: 'f5524783-e5d7-473e-b2a0-29582ff231da',
+ nhsNumber: '9991234566',
+ odsCode: 'A12345',
+ },
+};
+
+export const validSender: Sender = {
+ senderId: 'sender-123',
+ routingConfigId: 'routing-config-123',
+ senderName: 'Test Sender',
+ meshMailboxSenderId: 'meshMailBoxSender-123',
+ meshMailboxReportsId: 'meshMailBoxReports-123',
+ fallbackWaitTimeSeconds: 100,
+};
diff --git a/lambdas/core-notifier-lambda/src/__tests__/container.test.ts b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts
new file mode 100644
index 000000000..eac4385e8
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts
@@ -0,0 +1,132 @@
+import { mock } from 'jest-mock-extended';
+import { EventPublisher, ParameterStoreCache, logger } from 'utils';
+import { NotifyClient } from 'app/notify-api-client';
+import { NotifyMessageProcessor } from 'app/notify-message-processor';
+import { ISenderManagement, SenderManagement } from 'sender-management';
+import { createContainer } from 'container';
+import { loadConfig } from 'infra/config';
+
+jest.mock('utils', () => ({
+ ParameterStoreCache: jest.fn(),
+ createGetApimAccessToken: jest.fn(() => jest.fn()),
+ logger: {
+ info: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+ EventPublisher: jest.fn(),
+ eventBridgeClient: {},
+ sqsClient: {},
+}));
+
+jest.mock('app/notify-api-client');
+jest.mock('app/notify-message-processor');
+jest.mock('sender-management');
+jest.mock('infra/config');
+
+describe('createContainer', () => {
+ const mockParameterStore = mock();
+ const mockConfig = {
+ eventPublisherEventBusArn:
+ 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
+ eventPublisherDlqUrl:
+ 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
+ apimAccessTokenSsmParameterName: '/test/apim/access-token',
+ apimBaseUrl: 'https://api.test.nhs.uk',
+ environment: 'test',
+ };
+
+ const mockSenderManagement = mock();
+ const mockNotifyClient = mock();
+ const mockNotifyMessageProcessor = mock();
+ const mockEventPublisher = mock();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ (ParameterStoreCache as jest.Mock).mockImplementation(
+ () => mockParameterStore,
+ );
+ (loadConfig as jest.Mock).mockReturnValue(mockConfig);
+ (SenderManagement as jest.Mock).mockImplementation(
+ () => mockSenderManagement,
+ );
+ (NotifyClient as jest.Mock).mockImplementation(() => mockNotifyClient);
+ (NotifyMessageProcessor as jest.Mock).mockImplementation(
+ () => mockNotifyMessageProcessor,
+ );
+ (EventPublisher as jest.Mock).mockImplementation(() => mockEventPublisher);
+ });
+
+ it('creates and returns a container with all dependencies', async () => {
+ const container = await createContainer();
+
+ expect(container).toEqual({
+ notifyMessageProcessor: mockNotifyMessageProcessor,
+ logger,
+ senderManagement: mockSenderManagement,
+ eventPublisher: mockEventPublisher,
+ });
+ });
+
+ it('initializes ParameterStoreCache', async () => {
+ await createContainer();
+
+ expect(ParameterStoreCache).toHaveBeenCalledTimes(1);
+ expect(ParameterStoreCache).toHaveBeenCalledWith();
+ });
+
+ it('loads configuration', async () => {
+ await createContainer();
+
+ expect(loadConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it('creates SenderManagement with parameter store', async () => {
+ await createContainer();
+
+ expect(SenderManagement).toHaveBeenCalledTimes(1);
+ expect(SenderManagement).toHaveBeenCalledWith({
+ parameterStore: mockParameterStore,
+ });
+ });
+
+ it('creates NotifyClient with config and dependencies', async () => {
+ await createContainer();
+
+ expect(NotifyClient).toHaveBeenCalledTimes(1);
+ expect(NotifyClient).toHaveBeenCalledWith(
+ mockConfig.apimBaseUrl,
+ expect.objectContaining({
+ getAccessToken: expect.any(Function),
+ }),
+ logger,
+ );
+ });
+
+ it('creates NotifyMessageProcessor with client and logger', async () => {
+ await createContainer();
+
+ expect(NotifyMessageProcessor).toHaveBeenCalledTimes(1);
+ expect(NotifyMessageProcessor).toHaveBeenCalledWith({
+ nhsNotifyClient: mockNotifyClient,
+ logger,
+ });
+ });
+
+ it('creates EventPublisher instances with config', async () => {
+ await createContainer();
+
+ expect(EventPublisher).toHaveBeenCalledTimes(1);
+ expect(EventPublisher).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventBusArn: mockConfig.eventPublisherEventBusArn,
+ dlqUrl: mockConfig.eventPublisherDlqUrl,
+ logger,
+ sqsClient: expect.any(Object),
+ eventBridgeClient: expect.any(Object),
+ }),
+ );
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts b/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts
new file mode 100644
index 000000000..e1bde3bc7
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/domain/mapper.test.ts
@@ -0,0 +1,338 @@
+import { Sender, logger } from 'utils';
+import {
+ mapPdmEventToMessageRequestRejected,
+ mapPdmEventToMessageRequestSkipped,
+ mapPdmEventToMessageRequestSubmitted,
+ mapPdmEventToSingleMessageRequest,
+} from 'domain/mapper';
+import { PDMResourceAvailable } from 'digital-letters-events';
+import { randomUUID } from 'node:crypto';
+
+jest.mock('utils');
+jest.mock('node:crypto');
+
+const mockLogger = jest.mocked(logger);
+const mockRandomUUID = jest.mocked(randomUUID);
+
+describe('mapper', () => {
+ const mockSender: Sender = {
+ senderId: 'test-sender-id',
+ senderName: 'Test Sender',
+ meshMailboxSenderId: 'mesh-sender',
+ meshMailboxReportsId: 'mesh-reports',
+ fallbackWaitTimeSeconds: 300,
+ routingConfigId: 'routing-config-123',
+ };
+
+ const mockPdmEvent: PDMResourceAvailable = {
+ specversion: '1.0',
+ id: 'event-123',
+ source: 'pdm-service',
+ subject: 'resource/available',
+ type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1',
+ time: '2024-01-15T10:30:00Z',
+ datacontenttype: 'application/json',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json',
+ data: {
+ messageReference: 'msg-ref-123',
+ senderId: 'sender-456',
+ resourceId: 'resource-789',
+ nhsNumber: '9999999999',
+ odsCode: 'ODS123',
+ },
+ traceparent: '00-trace-parent',
+ recordedtime: '2024-01-15T10:30:00Z',
+ severitynumber: 2,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRandomUUID.mockReturnValue('45e7d942-0d33-46d1-a678-ada01e5de9fe');
+ });
+
+ describe('mapPdmEventToSingleMessageRequest', () => {
+ it('correctly maps PDM event to single message request', () => {
+ const result = mapPdmEventToSingleMessageRequest(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result).toEqual({
+ data: {
+ type: 'Message',
+ attributes: {
+ routingPlanId: 'routing-config-123',
+ messageReference: 'msg-ref-123',
+ billingReference: 'test-sender-id',
+ recipient: {
+ nhsNumber: '9999999999',
+ },
+ originator: {
+ odsCode: 'ODS123',
+ },
+ personalisation: {
+ digitalLetterURL:
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789',
+ },
+ },
+ },
+ });
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Mapping resource available',
+ messageReference: 'msg-ref-123',
+ senderId: 'test-sender-id',
+ });
+ });
+ });
+
+ describe('mapPdmEventToMessageRequestSubmitted', () => {
+ it('correctly maps PDM event to MessageRequestSubmitted', () => {
+ const notifyId = 'notify-123';
+ const mockDate = new Date('2024-01-15T12:00:00Z');
+ jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any);
+
+ const result = mapPdmEventToMessageRequestSubmitted(
+ mockPdmEvent,
+ mockSender,
+ notifyId,
+ );
+
+ expect(result).toEqual({
+ ...mockPdmEvent,
+ id: '45e7d942-0d33-46d1-a678-ada01e5de9fe',
+ time: '2024-01-15T12:00:00.000Z',
+ recordedtime: '2024-01-15T12:00:00.000Z',
+ type: 'uk.nhs.notify.digital.letters.messages.request.submitted.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-submitted-data.schema.json',
+ data: {
+ messageReference: 'msg-ref-123',
+ senderId: 'test-sender-id',
+ notifyId: 'notify-123',
+ messageUri:
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789',
+ },
+ });
+
+ expect(mockRandomUUID).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('mapPdmEventToMessageRequestSkipped', () => {
+ it('correctly maps PDM event to MessageRequestSkipped', () => {
+ const mockDate = new Date('2024-01-15T12:00:00Z');
+ jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any);
+
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result).toEqual({
+ ...mockPdmEvent,
+ id: '45e7d942-0d33-46d1-a678-ada01e5de9fe',
+ time: '2024-01-15T12:00:00.000Z',
+ recordedtime: '2024-01-15T12:00:00.000Z',
+ type: 'uk.nhs.notify.digital.letters.messages.request.skipped.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json',
+ data: {
+ messageReference: 'msg-ref-123',
+ senderId: 'test-sender-id',
+ },
+ });
+
+ expect(mockRandomUUID).toHaveBeenCalled();
+ });
+
+ it('generates new UUID for event', () => {
+ mapPdmEventToMessageRequestSkipped(mockPdmEvent, mockSender);
+
+ expect(mockRandomUUID).toHaveBeenCalledTimes(1);
+ });
+
+ it('includes messageReference in data', () => {
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result.data.messageReference).toBe('msg-ref-123');
+ });
+
+ it('uses sender senderId in data', () => {
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result.data.senderId).toBe('test-sender-id');
+ });
+
+ it('sets correct event type', () => {
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result.type).toBe(
+ 'uk.nhs.notify.digital.letters.messages.request.skipped.v1',
+ );
+ });
+
+ it('sets correct dataschema', () => {
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result.dataschema).toBe(
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json',
+ );
+ });
+
+ it('preserves CloudEvents properties from PDM event', () => {
+ const result = mapPdmEventToMessageRequestSkipped(
+ mockPdmEvent,
+ mockSender,
+ );
+
+ expect(result.specversion).toBe('1.0');
+ expect(result.source).toBe('pdm-service');
+ expect(result.subject).toBe('resource/available');
+ expect(result.traceparent).toBe('00-trace-parent');
+ });
+ });
+
+ describe('mapPdmEventToMessageRequestRejected', () => {
+ it('correctly maps PDM event to MessageRequestRejected', () => {
+ const failureCode = 'INVALID_NHS_NUMBER';
+ const mockDate = new Date('2024-01-15T12:00:00Z');
+ jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any);
+
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result).toEqual({
+ ...mockPdmEvent,
+ id: '45e7d942-0d33-46d1-a678-ada01e5de9fe',
+ time: '2024-01-15T12:00:00.000Z',
+ recordedtime: '2024-01-15T12:00:00.000Z',
+ type: 'uk.nhs.notify.digital.letters.messages.request.rejected.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json',
+ data: {
+ messageReference: 'msg-ref-123',
+ senderId: 'test-sender-id',
+ failureCode: 'INVALID_NHS_NUMBER',
+ messageUri:
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789',
+ },
+ });
+
+ expect(mockRandomUUID).toHaveBeenCalled();
+ });
+
+ it('generates new UUID for event', () => {
+ const failureCode = 'VALIDATION_ERROR';
+ mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(mockRandomUUID).toHaveBeenCalledTimes(1);
+ });
+
+ it('includes failureCode in data', () => {
+ const failureCode = 'ROUTING_FAILED';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.data.failureCode).toBe('ROUTING_FAILED');
+ });
+
+ it('includes messageUri with resource ID', () => {
+ const failureCode = 'TIMEOUT';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.data.messageUri).toBe(
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789',
+ );
+ });
+
+ it('uses sender senderId in data', () => {
+ const failureCode = 'UNKNOWN_ERROR';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.data.senderId).toBe('test-sender-id');
+ });
+
+ it('uses messageReference from PDM event', () => {
+ const failureCode = 'DUPLICATE_REQUEST';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.data.messageReference).toBe('msg-ref-123');
+ });
+
+ it('sets correct event type', () => {
+ const failureCode = 'SYSTEM_ERROR';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.type).toBe(
+ 'uk.nhs.notify.digital.letters.messages.request.rejected.v1',
+ );
+ });
+
+ it('sets correct dataschema', () => {
+ const failureCode = 'CONFIG_ERROR';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.dataschema).toBe(
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json',
+ );
+ });
+
+ it('preserves CloudEvents properties from PDM event', () => {
+ const failureCode = 'NETWORK_ERROR';
+ const result = mapPdmEventToMessageRequestRejected(
+ mockPdmEvent,
+ mockSender,
+ failureCode,
+ );
+
+ expect(result.specversion).toBe('1.0');
+ expect(result.source).toBe('pdm-service');
+ expect(result.subject).toBe('resource/available');
+ expect(result.traceparent).toBe('00-trace-parent');
+ });
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/index.test.ts b/lambdas/core-notifier-lambda/src/__tests__/index.test.ts
new file mode 100644
index 000000000..ee9c6f51e
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/index.test.ts
@@ -0,0 +1,96 @@
+import type { SQSEvent, SQSRecord } from 'aws-lambda';
+import { handler } from 'index';
+import { createContainer } from 'container';
+import { createHandler as createSqsHandler } from 'apis/sqs-handler';
+import type { SqsHandlerDependencies } from 'apis/sqs-handler';
+import { mock } from 'jest-mock-extended';
+
+jest.mock('container');
+jest.mock('apis/sqs-handler');
+
+const createSqsEvent = (recordCount: number): SQSEvent => ({
+ Records: Array.from(
+ { length: recordCount },
+ (_, i): SQSRecord => ({
+ messageId: `message-id-${i + 1}`,
+ receiptHandle: `receipt-handle-${i + 1}`,
+ body: JSON.stringify({
+ detail: {
+ id: `event-id-${i + 1}`,
+ source: 'test',
+ specversion: '1.0',
+ type: 'test.event',
+ time: '2025-12-16T10:00:00Z',
+ datacontenttype: 'application/json',
+ data: {},
+ },
+ }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ }),
+ ),
+});
+
+describe('Lambda handler', () => {
+ const mockContainer = mock();
+ const mockSqsHandler = jest.fn();
+ const mockCreateContainer = jest.mocked(createContainer);
+ const mockCreateSqsHandler = jest.mocked(createSqsHandler);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCreateContainer.mockResolvedValue(mockContainer);
+ mockCreateSqsHandler.mockReturnValue(mockSqsHandler);
+ mockSqsHandler.mockResolvedValue({ batchItemFailures: [] });
+ });
+
+ it('creates an SQS handler with the container dependencies and handler being invoked', async () => {
+ const sqsEvent = createSqsEvent(1);
+
+ await handler(sqsEvent);
+
+ expect(mockCreateSqsHandler).toHaveBeenCalledTimes(1);
+ expect(mockCreateSqsHandler).toHaveBeenCalledWith(mockContainer);
+ expect(mockSqsHandler).toHaveBeenCalledTimes(1);
+ expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent);
+ });
+
+ it('when fails to process a message it returns the id of the failed message', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const expectedResult = {
+ batchItemFailures: [{ itemIdentifier: 'message-id-1' }],
+ };
+ mockSqsHandler.mockResolvedValue(expectedResult);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('propagates errors from createContainer', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const error = new Error('Failed to create container');
+ mockCreateContainer.mockRejectedValue(error);
+
+ await expect(handler(sqsEvent)).rejects.toThrow(
+ 'Failed to create container',
+ );
+ });
+
+ it('propagates errors from the SQS handler', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const error = new Error('Handler failed');
+ mockSqsHandler.mockRejectedValue(error);
+
+ await expect(handler(sqsEvent)).rejects.toThrow('Handler failed');
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts
new file mode 100644
index 000000000..6f19f2e05
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts
@@ -0,0 +1,68 @@
+import { NotifySendMessageConfig, loadConfig } from 'infra/config';
+import { defaultConfigReader } from 'utils';
+
+jest.mock('utils', () => ({
+ defaultConfigReader: {
+ getValue: jest.fn(),
+ },
+}));
+
+describe('loadConfig', () => {
+ const mockGetValue = jest.mocked(defaultConfigReader.getValue);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('loads all configuration values from environment', () => {
+ const mockConfig = {
+ eventPublisherEventBusArn:
+ 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
+ eventPublisherDlqUrl:
+ 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
+ apimAccessTokenSsmParameterName: '/test/apim/access-token',
+ apimBaseUrl: 'https://api.test.nhs.uk',
+ environment: 'test',
+ };
+
+ mockGetValue
+ .mockReturnValueOnce(mockConfig.eventPublisherEventBusArn)
+ .mockReturnValueOnce(mockConfig.eventPublisherDlqUrl)
+ .mockReturnValueOnce(mockConfig.apimAccessTokenSsmParameterName)
+ .mockReturnValueOnce(mockConfig.apimBaseUrl)
+ .mockReturnValueOnce(mockConfig.environment);
+
+ const result = loadConfig();
+
+ expect(result).toEqual(mockConfig);
+ expect(mockGetValue).toHaveBeenCalledTimes(5);
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 1,
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(2, 'EVENT_PUBLISHER_DLQ_URL');
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 3,
+ 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(4, 'APIM_BASE_URL');
+ expect(mockGetValue).toHaveBeenNthCalledWith(5, 'ENVIRONMENT');
+ });
+
+ it('returns config with correct types', () => {
+ mockGetValue
+ .mockReturnValueOnce('arn:test')
+ .mockReturnValueOnce('https://dlq')
+ .mockReturnValueOnce('/param')
+ .mockReturnValueOnce('https://api')
+ .mockReturnValueOnce('prod');
+
+ const result: NotifySendMessageConfig = loadConfig();
+
+ expect(typeof result.eventPublisherEventBusArn).toBe('string');
+ expect(typeof result.eventPublisherDlqUrl).toBe('string');
+ expect(typeof result.apimAccessTokenSsmParameterName).toBe('string');
+ expect(typeof result.apimBaseUrl).toBe('string');
+ expect(typeof result.environment).toBe('string');
+ });
+});
diff --git a/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts b/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts
new file mode 100644
index 000000000..82ab8fc4c
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/apis/sqs-handler.ts
@@ -0,0 +1,169 @@
+import type {
+ SQSBatchItemFailure,
+ SQSBatchResponse,
+ SQSEvent,
+ SQSRecord,
+} from 'aws-lambda';
+import { EventPublisher, Logger, Sender } from 'utils';
+import {
+ MessageRequestRejected,
+ MessageRequestSkipped,
+ MessageRequestSubmitted,
+ PDMResourceAvailable,
+} from 'digital-letters-events';
+import {
+ mapPdmEventToMessageRequestRejected,
+ mapPdmEventToMessageRequestSkipped,
+ mapPdmEventToMessageRequestSubmitted,
+ mapPdmEventToSingleMessageRequest,
+} from 'domain/mapper';
+import messageRequestSubmittedValidator from 'digital-letters-events/MessageRequestSubmitted.js';
+import messageRequestRejectedValidator from 'digital-letters-events/MessageRequestRejected.js';
+import messageRequestSkippedValidator from 'digital-letters-events/MessageRequestSkipped.js';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+
+import type { NotifyMessageProcessor } from 'app/notify-message-processor';
+import { ISenderManagement } from 'sender-management';
+import { RequestNotifyError } from 'domain/request-notify-error';
+
+export interface SqsHandlerDependencies {
+ logger: Logger;
+ notifyMessageProcessor: NotifyMessageProcessor;
+ senderManagement: ISenderManagement;
+ eventPublisher: EventPublisher;
+}
+
+type EventToPublish = {
+ skipped?: MessageRequestSkipped;
+ submitted?: MessageRequestSubmitted;
+ rejected?: MessageRequestRejected;
+};
+
+async function handlePdmResourceAvailable(
+ notifyMessageProcessor: NotifyMessageProcessor,
+ incoming: PDMResourceAvailable,
+ sender: Sender,
+): Promise {
+ const eventToPublish = {} as EventToPublish;
+
+ if (sender.routingConfigId === undefined) {
+ const messageRequestSkipped = mapPdmEventToMessageRequestSkipped(
+ incoming,
+ sender,
+ );
+ eventToPublish.skipped = messageRequestSkipped;
+ } else {
+ const request = mapPdmEventToSingleMessageRequest(incoming, sender);
+ const notifyId = await notifyMessageProcessor.process(
+ request,
+ incoming.data.senderId,
+ );
+ const messageRequestSubmitted = mapPdmEventToMessageRequestSubmitted(
+ incoming,
+ sender,
+ notifyId,
+ );
+ eventToPublish.submitted = messageRequestSubmitted;
+ }
+
+ return eventToPublish;
+}
+
+export const createHandler = ({
+ eventPublisher,
+ logger,
+ notifyMessageProcessor,
+ senderManagement,
+}: SqsHandlerDependencies) =>
+ async function handler(sqsEvent: SQSEvent): Promise {
+ const receivedItemCount = sqsEvent.Records.length;
+
+ logger.info({
+ description: `Received SQS Event of ${receivedItemCount} record(s)`,
+ });
+
+ const batchItemFailures: SQSBatchItemFailure[] = [];
+ const skippedEvents: MessageRequestSkipped[] = [];
+ const submittedEvents: MessageRequestSubmitted[] = [];
+ const rejectedEvents: MessageRequestRejected[] = [];
+
+ let incoming: PDMResourceAvailable;
+ let sender: Sender | null;
+
+ await Promise.all(
+ sqsEvent.Records.map(async (sqsRecord: SQSRecord) => {
+ try {
+ incoming = parseSqsRecord(sqsRecord, logger);
+ sender = await senderManagement.getSender({
+ senderId: incoming.data.senderId,
+ });
+ if (sender === null) {
+ throw new Error(
+ `Sender not found for senderId: ${incoming.data.senderId}`,
+ );
+ }
+
+ const eventToPublish = await handlePdmResourceAvailable(
+ notifyMessageProcessor,
+ incoming,
+ sender,
+ );
+
+ if (eventToPublish.submitted) {
+ submittedEvents.push(eventToPublish.submitted);
+ }
+ if (eventToPublish.skipped) {
+ skippedEvents.push(eventToPublish.skipped);
+ }
+ } catch (error: any) {
+ logger.warn({
+ error: error.message,
+ description: 'Failed processing message',
+ messageId: sqsRecord.messageId,
+ senderId: incoming?.data.senderId,
+ });
+ if (error instanceof RequestNotifyError) {
+ // CCM-12858 A/C 5: When any other response other than a 201 is returned, don't retry the message
+ rejectedEvents.push(
+ mapPdmEventToMessageRequestRejected(
+ incoming,
+ sender!,
+ error.errorCode,
+ ),
+ );
+ } else {
+ // this might be a transient error so we notify the queue to retry
+ batchItemFailures.push({ itemIdentifier: sqsRecord.messageId });
+ }
+ }
+ }),
+ );
+
+ await Promise.all(
+ [
+ submittedEvents.length > 0 &&
+ eventPublisher.sendEvents(
+ submittedEvents,
+ messageRequestSubmittedValidator,
+ ),
+ skippedEvents.length > 0 &&
+ eventPublisher.sendEvents(
+ skippedEvents,
+ messageRequestSkippedValidator,
+ ),
+ rejectedEvents.length > 0 &&
+ eventPublisher.sendEvents(
+ rejectedEvents,
+ messageRequestRejectedValidator,
+ ),
+ ].filter(Boolean),
+ );
+
+ const processedItemCount = receivedItemCount - batchItemFailures.length;
+
+ logger.info({
+ description: `${processedItemCount} of ${receivedItemCount} records processed successfully`,
+ });
+
+ return { batchItemFailures };
+ };
diff --git a/lambdas/core-notifier-lambda/src/app/notify-api-client.ts b/lambdas/core-notifier-lambda/src/app/notify-api-client.ts
new file mode 100644
index 000000000..9f1ada7bc
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/app/notify-api-client.ts
@@ -0,0 +1,119 @@
+import axios, { AxiosInstance, isAxiosError } from 'axios';
+import type { AxiosError } from 'axios';
+import type { Readable } from 'node:stream';
+import { constants as HTTP2_CONSTANTS } from 'node:http2';
+import type {
+ SingleMessageErrorResponse,
+ SingleMessageRequest,
+ SingleMessageResponse,
+} from 'domain/request';
+import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
+import { RetryConfig, conditionalRetry } from 'utils';
+import type { Logger } from 'utils';
+import { RequestNotifyError } from 'domain/request-notify-error';
+
+export interface IAccessTokenRepository {
+ getAccessToken(): Promise;
+}
+
+export type Response = {
+ data: Readable;
+};
+
+export interface INotifyClient {
+ sendRequest(
+ apiRequest: SingleMessageRequest,
+ correlationId?: string,
+ ): Promise;
+}
+/*
+ * Client for sending requests to the NHS Notify API see
+ * https://digital.nhs.uk/developer/api-catalogue/nhs-notify
+ */
+export class NotifyClient implements INotifyClient {
+ private client: AxiosInstance;
+
+ constructor(
+ private apimBaseUrl: string,
+ private accessTokenRepository: IAccessTokenRepository,
+ private logger: Logger,
+ private backoffConfig: RetryConfig = {
+ maxDelayMs: 10_000,
+ intervalMs: 1000,
+ exponentialRate: 2,
+ maxAttempts: 10,
+ },
+ ) {
+ this.client = axios.create({
+ baseURL: this.apimBaseUrl,
+ });
+ }
+
+ public async sendRequest(
+ apiRequest: SingleMessageRequest,
+ correlationId: string,
+ ): Promise {
+ try {
+ return await conditionalRetry(
+ async (attempt) => {
+ const accessToken = await this.accessTokenRepository.getAccessToken();
+
+ this.logger.debug({
+ correlationId,
+ description: 'Sending request',
+ attempt,
+ });
+
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-Correlation-ID': correlationId,
+ ...(accessToken === ''
+ ? {}
+ : {
+ Authorization: `Bearer ${accessToken}`,
+ }),
+ };
+ const response = await this.client.post(
+ '/comms/v1/messages',
+ apiRequest,
+ { headers },
+ );
+
+ return response.data;
+ },
+ (err) =>
+ Boolean(
+ isAxiosError(err) &&
+ err.response?.status ===
+ HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS,
+ ),
+ this.backoffConfig,
+ );
+ } catch (error: any) {
+ this.logger.error({
+ description: 'Failed sending request to Notify',
+ err: error,
+ });
+
+ if (isAxiosError(error)) {
+ const axiosError: AxiosError = error;
+ if (
+ axiosError.response?.status ===
+ HTTP2_CONSTANTS.HTTP_STATUS_UNPROCESSABLE_ENTITY
+ ) {
+ throw new RequestAlreadyReceivedError(error, correlationId);
+ } else {
+ const errorBody: SingleMessageErrorResponse = axiosError.response
+ ?.data as SingleMessageErrorResponse;
+ throw new RequestNotifyError(
+ error,
+ correlationId,
+ errorBody?.errors[0].code,
+ );
+ }
+ }
+
+ throw error;
+ }
+ }
+}
diff --git a/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts b/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts
new file mode 100644
index 000000000..6f92e7478
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/app/notify-message-processor.ts
@@ -0,0 +1,69 @@
+import { Logger } from 'utils/logger';
+import type { NotifyClient } from 'app/notify-api-client';
+import type { SingleMessageRequest } from 'domain/request';
+import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
+
+type Dependencies = {
+ nhsNotifyClient: NotifyClient;
+ logger: Logger;
+};
+
+export class NotifyMessageProcessor {
+ private readonly logger: Logger;
+
+ private readonly nhsNotifyClient: NotifyClient;
+
+ constructor({ logger, nhsNotifyClient }: Dependencies) {
+ this.logger = logger;
+ this.nhsNotifyClient = nhsNotifyClient;
+ }
+
+ /**
+ *
+ * @param payload the single message request
+ * @returns the ID returned by Core Notify for a succesful response.
+ */
+ public async process(
+ payload: SingleMessageRequest,
+ senderId: string,
+ ): Promise {
+ const { messageReference } = payload.data.attributes;
+
+ this.logger.info({
+ description: 'Sending request to Notify API',
+ messageReference,
+ senderId,
+ });
+ try {
+ const response = await this.nhsNotifyClient.sendRequest(
+ payload,
+ messageReference,
+ );
+ const messageItemId = response.data.id;
+ this.logger.info({
+ description: 'Successfully processed request and sent to Notify',
+ messageReference,
+ senderId,
+ messageItemId,
+ });
+ return messageItemId;
+ } catch (error: any) {
+ if (error instanceof RequestAlreadyReceivedError) {
+ this.logger.info({
+ description: 'Request has already been received by Notify',
+ messageReference,
+ senderId,
+ });
+ throw error;
+ }
+
+ this.logger.error({
+ description: 'Failed sending request to Notify API',
+ messageReference,
+ senderId,
+ error: error.message,
+ });
+ throw error;
+ }
+ }
+}
diff --git a/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts b/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts
new file mode 100644
index 000000000..0bb3aa1d1
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/app/parse-sqs-message.ts
@@ -0,0 +1,38 @@
+import { SQSRecord } from 'aws-lambda';
+import { Logger } from 'utils';
+import { PDMResourceAvailable } from 'digital-letters-events';
+import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event';
+import messagePDMResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js';
+
+export const parseSqsRecord = (
+ sqsRecord: SQSRecord,
+ logger: Logger,
+): PDMResourceAvailable => {
+ logger.info({
+ description: 'Parsing SQS Record',
+ messageId: sqsRecord.messageId,
+ });
+
+ const sqsEventBody = JSON.parse(sqsRecord.body);
+ const sqsEventDetail = sqsEventBody.detail;
+
+ if (!messagePDMResourceAvailableValidator(sqsEventDetail)) {
+ logger.error({
+ error: messagePDMResourceAvailableValidator.errors,
+ description:
+ 'The SQS message does not contain a valid PDMResourceAvailable event',
+ messageId: sqsRecord.messageId,
+ });
+ throw new InvalidPdmResourceAvailableEvent(sqsRecord.messageId);
+ }
+
+ logger.info({
+ description: 'Parsed valid PDMResourceAvailable Event',
+ messageId: sqsRecord.messageId,
+ messageReference: sqsEventDetail.data.messageReference,
+ senderId: sqsEventDetail.data.senderId,
+ resourceId: sqsEventDetail.data.resourceId,
+ });
+
+ return sqsEventDetail;
+};
diff --git a/lambdas/core-notifier-lambda/src/container.ts b/lambdas/core-notifier-lambda/src/container.ts
new file mode 100644
index 000000000..6dfadfed9
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/container.ts
@@ -0,0 +1,57 @@
+import {
+ EventPublisher,
+ ParameterStoreCache,
+ createGetApimAccessToken,
+ eventBridgeClient,
+ logger,
+ sqsClient,
+} from 'utils';
+import { NotifyClient } from 'app/notify-api-client';
+import { NotifyMessageProcessor } from 'app/notify-message-processor';
+import type { SqsHandlerDependencies } from 'apis/sqs-handler';
+import { loadConfig } from 'infra/config';
+import { SenderManagement } from 'sender-management';
+
+export async function createContainer(): Promise {
+ const parameterStore = new ParameterStoreCache();
+ const config = loadConfig();
+ const senderManagement = SenderManagement({
+ parameterStore,
+ });
+
+ const accessTokenRepository = {
+ getAccessToken: createGetApimAccessToken(
+ config.apimAccessTokenSsmParameterName,
+ logger,
+ parameterStore,
+ ),
+ };
+
+ const notifyClient = new NotifyClient(
+ config.apimBaseUrl,
+ accessTokenRepository,
+ logger,
+ );
+
+ const notifyMessageProcessor = new NotifyMessageProcessor({
+ nhsNotifyClient: notifyClient,
+ logger,
+ });
+
+ const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config;
+
+ const eventPublisher = new EventPublisher({
+ eventBusArn: eventPublisherEventBusArn,
+ dlqUrl: eventPublisherDlqUrl,
+ logger,
+ sqsClient,
+ eventBridgeClient,
+ });
+
+ return {
+ logger,
+ notifyMessageProcessor,
+ senderManagement,
+ eventPublisher,
+ };
+}
diff --git a/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts b/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts
new file mode 100644
index 000000000..f803a3f9a
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/domain/invalid-pdm-resource-available-event.ts
@@ -0,0 +1,8 @@
+export class InvalidPdmResourceAvailableEvent extends Error {
+ readonly sqsMessageId: string;
+
+ constructor(sqsMessageId: string) {
+ super('Unable to parse PDMResourceAvailable event from SQS message');
+ this.sqsMessageId = sqsMessageId;
+ }
+}
diff --git a/lambdas/core-notifier-lambda/src/domain/mapper.ts b/lambdas/core-notifier-lambda/src/domain/mapper.ts
new file mode 100644
index 000000000..61455bede
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/domain/mapper.ts
@@ -0,0 +1,122 @@
+import { Sender, logger } from 'utils';
+import { randomUUID } from 'node:crypto';
+import {
+ MessageRequestRejected,
+ MessageRequestSkipped,
+ MessageRequestSubmitted,
+ PDMResourceAvailable,
+} from 'digital-letters-events';
+import type { SingleMessageRequest } from 'domain/request';
+
+const DIGITAL_LETTER_URL =
+ 'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=';
+
+export function mapPdmEventToSingleMessageRequest(
+ pdmResourceAvailable: PDMResourceAvailable,
+ sender: Sender,
+): SingleMessageRequest {
+ const { data } = pdmResourceAvailable;
+ const { messageReference } = data;
+
+ logger.info({
+ description: 'Mapping resource available',
+ messageReference,
+ senderId: sender.senderId,
+ });
+
+ const request: SingleMessageRequest = {
+ data: {
+ type: 'Message',
+ attributes: {
+ routingPlanId: sender.routingConfigId!,
+ messageReference,
+ billingReference: sender.senderId,
+ recipient: {
+ nhsNumber: data.nhsNumber,
+ },
+ originator: {
+ odsCode: data.odsCode,
+ },
+ personalisation: {
+ digitalLetterURL: `${DIGITAL_LETTER_URL}${data.resourceId}`,
+ },
+ },
+ },
+ };
+ return request;
+}
+
+export function mapPdmEventToMessageRequestSubmitted(
+ pdmResourceAvailable: PDMResourceAvailable,
+ sender: Sender,
+ notifyId: string,
+): MessageRequestSubmitted {
+ const { data } = pdmResourceAvailable;
+ const { messageReference } = data;
+
+ return {
+ ...pdmResourceAvailable,
+ id: randomUUID(),
+ time: new Date().toISOString(),
+ recordedtime: new Date().toISOString(),
+ type: 'uk.nhs.notify.digital.letters.messages.request.submitted.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-submitted-data.schema.json',
+ source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'),
+ data: {
+ messageReference,
+ senderId: sender.senderId,
+ notifyId,
+ messageUri: `${DIGITAL_LETTER_URL}${data.resourceId}`,
+ },
+ };
+}
+
+export function mapPdmEventToMessageRequestSkipped(
+ pdmResourceAvailable: PDMResourceAvailable,
+ sender: Sender,
+): MessageRequestSkipped {
+ const { data } = pdmResourceAvailable;
+ const { messageReference } = data;
+
+ return {
+ ...pdmResourceAvailable,
+ id: randomUUID(),
+ time: new Date().toISOString(),
+ recordedtime: new Date().toISOString(),
+ type: 'uk.nhs.notify.digital.letters.messages.request.skipped.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-skipped-data.schema.json',
+ source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'),
+ data: {
+ messageReference,
+ senderId: sender.senderId,
+ },
+ };
+}
+
+export function mapPdmEventToMessageRequestRejected(
+ pdmResourceAvailable: PDMResourceAvailable,
+ sender: Sender,
+ notifyFailureCode: string,
+): MessageRequestRejected {
+ const { data } = pdmResourceAvailable;
+ const { messageReference } = data;
+
+ return {
+ ...pdmResourceAvailable,
+ id: randomUUID(),
+ time: new Date().toISOString(),
+ recordedtime: new Date().toISOString(),
+ type: 'uk.nhs.notify.digital.letters.messages.request.rejected.v1',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-message-request-rejected-data.schema.json',
+ source: pdmResourceAvailable.source.replace(/\/pdm$/, '/messages'),
+ data: {
+ messageReference,
+ senderId: sender.senderId,
+ failureCode: notifyFailureCode,
+ messageUri: `${DIGITAL_LETTER_URL}${data.resourceId}`,
+ },
+ };
+}
diff --git a/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts b/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts
new file mode 100644
index 000000000..d4ebaff0b
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/domain/request-already-received-error.ts
@@ -0,0 +1,12 @@
+export class RequestAlreadyReceivedError extends Error {
+ readonly cause: Error;
+
+ readonly correlationId: string;
+
+ constructor(cause: Error, correlationId: string) {
+ super('The request has already been received.');
+
+ this.cause = cause;
+ this.correlationId = correlationId;
+ }
+}
diff --git a/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts b/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts
new file mode 100644
index 000000000..98d0fdf98
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/domain/request-notify-error.ts
@@ -0,0 +1,19 @@
+/**
+ * To represent an HTTP status not 2xx returned from Notify API when sending a Single Message Request.
+ * Note that 429 and 422 are handled separately in the Notify API client.
+ */
+export class RequestNotifyError extends Error {
+ readonly cause: Error;
+
+ readonly correlationId: string;
+
+ readonly errorCode: string;
+
+ constructor(cause: Error, correlationId: string, errorCode: string) {
+ super('Error received from Core Notify API');
+
+ this.cause = cause;
+ this.correlationId = correlationId;
+ this.errorCode = errorCode;
+ }
+}
diff --git a/lambdas/core-notifier-lambda/src/domain/request.ts b/lambdas/core-notifier-lambda/src/domain/request.ts
new file mode 100644
index 000000000..b5860b5d8
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/domain/request.ts
@@ -0,0 +1,51 @@
+export type SingleMessageRequest = {
+ data: {
+ type: string;
+ attributes: {
+ routingPlanId: string;
+ messageReference: string;
+ billingReference: string;
+ recipient: {
+ nhsNumber: string;
+ };
+ originator: {
+ odsCode: string;
+ };
+ personalisation: {
+ digitalLetterURL: string;
+ };
+ };
+ };
+};
+
+export type SingleMessageResponse = {
+ data: {
+ type: string;
+ id: string;
+ attributes: {
+ messageReference: string;
+ messageStatus: string;
+ timestamps: {
+ created: string;
+ };
+ routingPlan: {
+ id: string;
+ version: string;
+ createdDate: string;
+ name: string;
+ };
+ };
+ links: {
+ self: string;
+ };
+ };
+};
+
+export type SingleMessageErrorResponse = {
+ errors: [
+ {
+ id: string;
+ code: string;
+ },
+ ];
+};
diff --git a/lambdas/core-notifier-lambda/src/index.ts b/lambdas/core-notifier-lambda/src/index.ts
new file mode 100644
index 000000000..655bf5054
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/index.ts
@@ -0,0 +1,10 @@
+// This is a Lambda entrypoint file.
+import type { SQSEvent } from 'aws-lambda';
+import { createContainer } from 'container';
+import { createHandler as createSqsHandler } from 'apis/sqs-handler';
+
+export const handler = async (sqsEvent: SQSEvent) => {
+ const container = await createContainer();
+ const sqsHandler = createSqsHandler(container);
+ return sqsHandler(sqsEvent);
+};
diff --git a/lambdas/core-notifier-lambda/src/infra/config.ts b/lambdas/core-notifier-lambda/src/infra/config.ts
new file mode 100644
index 000000000..745b63793
--- /dev/null
+++ b/lambdas/core-notifier-lambda/src/infra/config.ts
@@ -0,0 +1,25 @@
+import { defaultConfigReader } from 'utils';
+
+export type NotifySendMessageConfig = {
+ eventPublisherEventBusArn: string;
+ eventPublisherDlqUrl: string;
+ apimAccessTokenSsmParameterName: string;
+ apimBaseUrl: string;
+ environment: string;
+};
+
+export function loadConfig(): NotifySendMessageConfig {
+ return {
+ eventPublisherEventBusArn: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ ),
+ eventPublisherDlqUrl: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_DLQ_URL',
+ ),
+ apimAccessTokenSsmParameterName: defaultConfigReader.getValue(
+ 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME',
+ ),
+ apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'),
+ environment: defaultConfigReader.getValue('ENVIRONMENT'),
+ };
+}
diff --git a/lambdas/core-notifier-lambda/tsconfig.json b/lambdas/core-notifier-lambda/tsconfig.json
new file mode 100644
index 000000000..f7bcaa1ff
--- /dev/null
+++ b/lambdas/core-notifier-lambda/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./src/",
+ "isolatedModules": true
+ },
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "include": [
+ "src/**/*",
+ "jest.config.ts"
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index dad97a7b7..07ad56f2b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"lambdas/ttl-handle-expiry-lambda",
"lambdas/ttl-poll-lambda",
"lambdas/pdm-uploader-lambda",
+ "lambdas/core-notifier-lambda",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
@@ -60,6 +61,100 @@
"typescript-eslint": "^8.46.1"
}
},
+ "lambdas/core-notifier": {
+ "name": "nhs-notify-digital-core-notifier",
+ "version": "0.0.1",
+ "extraneous": true,
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "axios": "1.10.0",
+ "digital-letters-events": "^0.0.1",
+ "sender-management": "^0.0.1",
+ "utils": "^0.0.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "aws-sdk-client-mock": "^4.1.0",
+ "aws-sdk-client-mock-jest": "^4.1.0",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/core-notifier-lambda": {
+ "name": "nhs-notify-digital-core-notifier-lambda",
+ "version": "0.0.1",
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "axios": "^1.13.2",
+ "digital-letters-events": "^0.0.1",
+ "sender-management": "^0.0.1",
+ "utils": "^0.0.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/aws-lambda": "^8.10.155",
+ "@types/jest": "^29.5.14",
+ "jest": "^29.7.0",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/core-notifier-lambda/node_modules/@types/jest": {
+ "version": "29.5.14",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
+ "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
+ "lambdas/core-notifier-lambda/node_modules/jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
+ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "import-local": "^3.0.2",
+ "jest-cli": "^29.7.0"
+ },
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "lambdas/core-notifier-lambda/node_modules/jest-mock-extended": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz",
+ "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ts-essentials": "^10.0.0"
+ },
+ "peerDependencies": {
+ "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0",
+ "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
"lambdas/key-generation": {
"version": "0.0.1",
"dependencies": {
@@ -12241,6 +12336,10 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nhs-notify-digital-core-notifier-lambda": {
+ "resolved": "lambdas/core-notifier-lambda",
+ "link": true
+ },
"node_modules/nhs-notify-digital-letters-integration-tests": {
"resolved": "tests/playwright",
"link": true
diff --git a/package.json b/package.json
index 5208ebc85..4e4c5e124 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"lambdas/ttl-handle-expiry-lambda",
"lambdas/ttl-poll-lambda",
"lambdas/pdm-uploader-lambda",
+ "lambdas/core-notifier-lambda",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
diff --git a/tests/playwright/config/component/senders.setup.ts b/tests/playwright/config/component/senders.setup.ts
index 68e5aa48d..43fb2addb 100644
--- a/tests/playwright/config/component/senders.setup.ts
+++ b/tests/playwright/config/component/senders.setup.ts
@@ -1,15 +1,36 @@
import { test as setup } from '@playwright/test';
import senderRepository from 'helpers/sender-helpers';
import { Sender } from 'utils';
+import {
+ SENDER_ID_SKIPS_NOTIFY,
+ SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX,
+ SENDER_ID_VALID_FOR_NOTIFY_SANDBOX,
+} from 'constants/tests-constants';
const testSenders: Sender[] = [
{
- senderId: 'test-sender-1',
+ senderId: SENDER_ID_SKIPS_NOTIFY,
senderName: 'Test Sender 1',
meshMailboxSenderId: 'test-mesh-sender-1',
meshMailboxReportsId: 'test-mesh-reports-1',
fallbackWaitTimeSeconds: 24 * 3600,
},
+ {
+ senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX,
+ senderName: 'componentTestSender_RoutingConfig',
+ meshMailboxSenderId: 'meshMailboxSender1',
+ meshMailboxReportsId: 'meshMailboxReports1',
+ routingConfigId: 'b838b13c-f98c-4def-93f0-515d4e4f4ee1',
+ fallbackWaitTimeSeconds: 100,
+ },
+ {
+ senderId: SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX,
+ senderName: 'componentTestSender_RoutingConfigInvalid',
+ meshMailboxSenderId: 'meshMailboxSender2',
+ meshMailboxReportsId: 'meshMailboxReports2',
+ routingConfigId: 'invalid',
+ fallbackWaitTimeSeconds: 100,
+ },
];
setup('Create senders', async () => {
diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts
index fd2fae572..0af36d564 100644
--- a/tests/playwright/constants/backend-constants.ts
+++ b/tests/playwright/constants/backend-constants.ts
@@ -11,12 +11,14 @@ export const CSI = `nhs-${ENV}-dl`;
export const MESH_POLL_LAMBDA_NAME = `${CSI}-mesh-poll`;
export const TTL_CREATE_LAMBDA_NAME = `${CSI}-ttl-create`;
export const TTL_POLL_LAMBDA_NAME = `${CSI}-ttl-poll`;
+export const CORE_NOTIFIER_LAMBDA_NAME = `${CSI}-core-notifier`;
// Queue Names
export const TTL_QUEUE_NAME = `${CSI}-ttl-queue`;
export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`;
export const PDM_UPLOADER_DLQ_NAME = `${CSI}-pdm-uploader-dlq`;
export const PDM_POLL_DLQ_NAME = `${CSI}-pdm-poll-dlq`;
+export const CORE_NOTIFIER_DLQ_NAME = `${CSI}-core-notifier-dlq`;
// Queue Url Prefix
export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`;
@@ -35,3 +37,4 @@ export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGIO
// Cloudwatch
export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`;
export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`;
+export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-notifier`;
diff --git a/tests/playwright/constants/tests-constants.ts b/tests/playwright/constants/tests-constants.ts
new file mode 100644
index 000000000..3936dc85b
--- /dev/null
+++ b/tests/playwright/constants/tests-constants.ts
@@ -0,0 +1,6 @@
+// senderIds
+export const SENDER_ID_VALID_FOR_NOTIFY_SANDBOX =
+ 'componentTestSender_RoutingConfig';
+export const SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX =
+ 'componentTestSender_RoutingConfigInvalid';
+export const SENDER_ID_SKIPS_NOTIFY = 'test-sender-1';
diff --git a/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts b/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts
new file mode 100644
index 000000000..af2d59703
--- /dev/null
+++ b/tests/playwright/digital-letters-component-tests/core-notify.component.spec.ts
@@ -0,0 +1,224 @@
+import { expect, test } from '@playwright/test';
+import {
+ CORE_NOTIFIER_DLQ_NAME,
+ CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME,
+ EVENT_BUS_LOG_GROUP_NAME,
+} from 'constants/backend-constants';
+import {
+ SENDER_ID_SKIPS_NOTIFY,
+ SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX,
+ SENDER_ID_VALID_FOR_NOTIFY_SANDBOX,
+} from 'constants/tests-constants';
+import { PDMResourceAvailable } from 'digital-letters-events';
+import messagePDMResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js';
+import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
+import eventPublisher from 'helpers/event-bus-helpers';
+import expectToPassEventually from 'helpers/expectations';
+import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
+import { v4 as uuidv4 } from 'uuid';
+
+const baseEvent: Omit = {
+ specversion: '1.0',
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm',
+ subject:
+ 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
+ type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1',
+ time: '2023-06-20T12:00:00Z',
+ recordedtime: '2023-06-20T12:00:00.250Z',
+ severitynumber: 2,
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ datacontenttype: 'application/json',
+ dataschema:
+ 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json',
+ severitytext: 'INFO',
+};
+
+test.describe('Digital Letters - Core Notify', () => {
+ test.beforeAll(async () => {
+ await purgeQueue(CORE_NOTIFIER_DLQ_NAME);
+ test.setTimeout(250_000);
+ });
+
+ test.afterAll(async () => {
+ await purgeQueue(CORE_NOTIFIER_DLQ_NAME);
+ });
+
+ test('given PDMResourceAvailable event, when client has routingConfigId then a message is sent to core Notify', async () => {
+ const eventId = uuidv4();
+ const messageReference = uuidv4();
+ const resourceId = 'resource-222';
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...baseEvent,
+ id: eventId,
+ data: {
+ messageReference,
+ senderId: SENDER_ID_VALID_FOR_NOTIFY_SANDBOX,
+ resourceId,
+ nhsNumber: '9990548609',
+ odsCode: 'A12345',
+ },
+ },
+ ],
+ messagePDMResourceAvailableValidator,
+ );
+
+ // Verify the event is processed and a message appears in the Lambda logs
+ await expectToPassEventually(async () => {
+ const filteredLogs = await getLogsFromCloudwatch(
+ CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME,
+ [
+ '$.message.description = "Successfully processed request and sent to Notify"',
+ `$.message.messageReference = "${messageReference}"`,
+ ],
+ );
+
+ expect(filteredLogs.length).toEqual(1);
+ }, 240);
+
+ // Verify the event is published in the event bus
+ await expectToPassEventually(async () => {
+ const eventLogEntry = await getLogsFromCloudwatch(
+ EVENT_BUS_LOG_GROUP_NAME,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.submitted.v1"',
+ `$.details.event_detail = "*\\"notifyId\\":\\"*\\"*"`,
+ `$.details.event_detail = "*\\"messageUri\\":\\"https://www.nhsapp.service.nhs.uk/digital-letters?letterid=${resourceId}\\"*"`,
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_VALID_FOR_NOTIFY_SANDBOX}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 240);
+ });
+
+ test('given PDMResourceAvailable event with a client configured with a Routing plan not recognized by the Core Notify sandbox, when the sandbox receives the event then it replies with an error', async () => {
+ const eventId = uuidv4();
+ const messageReference = uuidv4();
+ const resourceId = 'resource-999';
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...baseEvent,
+ id: eventId,
+ data: {
+ messageReference,
+ senderId: SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX,
+ resourceId,
+ nhsNumber: '9434765919',
+ odsCode: 'A12345',
+ },
+ },
+ ],
+ messagePDMResourceAvailableValidator,
+ );
+
+ // Verify the event is processed and a message appears in the Lambda logs
+ await expectToPassEventually(async () => {
+ const filteredLogs = await getLogsFromCloudwatch(
+ CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME,
+ [
+ '$.message.description = "Failed sending request to Notify API"',
+ `$.message.messageReference = "${messageReference}"`,
+ ],
+ );
+
+ expect(filteredLogs.length).toEqual(1);
+ }, 240);
+
+ // Verify the event is published in the event bus
+ await expectToPassEventually(async () => {
+ const eventLogEntry = await getLogsFromCloudwatch(
+ EVENT_BUS_LOG_GROUP_NAME,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.rejected.v1"',
+ `$.details.event_detail = "*\\"failureCode\\":\\"CM_INVALID_VALUE\\"*"`,
+ `$.details.event_detail = "*\\"messageUri\\":\\"https://www.nhsapp.service.nhs.uk/digital-letters?letterid=${resourceId}\\"*"`,
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_THAT_TRIGGERS_ERROR_IN_NOTIFY_SANDBOX}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 240);
+ });
+
+ test('given PDMResourceAvailable event, when client does NOT have routingConfigId then a message is NOT sent to core Notify', async () => {
+ const eventId = uuidv4();
+ const messageReference = uuidv4();
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...baseEvent,
+ id: eventId,
+ data: {
+ messageReference,
+ senderId: SENDER_ID_SKIPS_NOTIFY,
+ resourceId: 'resource-7777',
+ nhsNumber: '9990548609',
+ odsCode: 'A12345',
+ },
+ },
+ ],
+ messagePDMResourceAvailableValidator,
+ );
+
+ // Verify the event is published in the event bus
+ await expectToPassEventually(async () => {
+ const eventLogEntry = await getLogsFromCloudwatch(
+ EVENT_BUS_LOG_GROUP_NAME,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.messages.request.skipped.v1"',
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 240);
+ });
+
+ test('given PDMResourceAvailable event, when client does NOT exist then it goes to DLQ', async () => {
+ test.setTimeout(550_000);
+ const eventId = uuidv4();
+ const messageReference = uuidv4();
+
+ await eventPublisher.sendEvents(
+ [
+ {
+ ...baseEvent,
+ id: eventId,
+ data: {
+ messageReference,
+ senderId: 'senderId_that_does_not_exist',
+ resourceId: 'resource-1234',
+ nhsNumber: '9990548609',
+ odsCode: 'A12345',
+ },
+ },
+ ],
+ messagePDMResourceAvailableValidator,
+ );
+
+ // Verify the event is processed and a message appears in the Lambda logs
+ await expectToPassEventually(async () => {
+ const filteredLogs = await getLogsFromCloudwatch(
+ CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME,
+ ['$.message.description = "0 of 1 records processed successfully"'],
+ );
+
+ expect(filteredLogs.length).toBeGreaterThanOrEqual(1);
+ }, 240);
+ // Verify there is a message in the DLQ
+ await expectMessageContainingString(CORE_NOTIFIER_DLQ_NAME, eventId, 420);
+ });
+});
diff --git a/utils/sender-management/README.md b/utils/sender-management/README.md
index 7a71c952e..9b6f4e125 100644
--- a/utils/sender-management/README.md
+++ b/utils/sender-management/README.md
@@ -14,12 +14,12 @@ npm --prefix utils/sender-management run-script cli -- [options]
### Library Usage
-Install the package as `@sender-management`
+Install the package as `sender-management`
Instantiate an instance of the library as follows. The library should take an implementation of an `IParameterStore` to define how the library will interact with SSM (e.g. caching vs non-caching).
```ts
-import { SenderManagement } from '@sender-management';
+import { SenderManagement } from 'sender-management';
const sm = SenderManagement({ parameterStore: new ParameterStore() });
```