diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md
index d69eaeb..0040c28 100644
--- a/infrastructure/terraform/components/callbacks/README.md
+++ b/infrastructure/terraform/components/callbacks/README.md
@@ -32,6 +32,7 @@
| Name | Source | Version |
|------|--------|---------|
+| [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-s3bucket.zip | n/a |
| [client\_destination](#module\_client\_destination) | ../../modules/client-destination | n/a |
| [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/lambda | v2.0.29 |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-kms.zip | n/a |
diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
index c0ce5da..ebbb3b0 100644
--- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
+++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
@@ -15,7 +15,7 @@ module "client_transform_filter_lambda" {
kms_key_arn = module.kms.key_arn ## Requires shared kms module
iam_policy_document = {
- body = data.aws_iam_policy_document.example_lambda.json
+ body = data.aws_iam_policy_document.client_transform_filter_lambda.json
}
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
@@ -38,7 +38,7 @@ module "client_transform_filter_lambda" {
}
}
-data "aws_iam_policy_document" "example_lambda" {
+data "aws_iam_policy_document" "client_transform_filter_lambda" {
statement {
sid = "KMSPermissions"
effect = "Allow"
@@ -52,4 +52,17 @@ data "aws_iam_policy_document" "example_lambda" {
module.kms.key_arn, ## Requires shared kms module
]
}
+
+ statement {
+ sid = "S3ClientConfigReadAccess"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ ]
+
+ resources = [
+ "${module.client_config_bucket.arn}/*",
+ ]
+ }
}
diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
new file mode 100644
index 0000000..9f468ea
--- /dev/null
+++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
@@ -0,0 +1,86 @@
+##
+# S3 Bucket for Client Subscription Configuration
+#
+# Storage location for client subscription configurations loaded by Transform & Filter Lambda.
+# Files are named {clientId}.json and contain ClientSubscriptionConfiguration arrays.
+##
+
+module "client_config_bucket" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.28/terraform-s3bucket.zip"
+
+ name = "subscription-config"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+
+ default_tags = merge(
+ local.default_tags,
+ {
+ Description = "Client subscription configuration storage"
+ }
+ )
+
+ kms_key_arn = module.kms.key_arn
+ force_destroy = false
+ versioning = true
+ object_ownership = "BucketOwnerPreferred"
+ bucket_key_enabled = true
+
+ policy_documents = [
+ data.aws_iam_policy_document.client_config_bucket.json
+ ]
+}
+
+##
+# S3 Bucket Policy
+#
+# Allows Transform & Filter Lambda to read configuration files
+##
+
+data "aws_iam_policy_document" "client_config_bucket" {
+ statement {
+ sid = "AllowLambdaReadAccess"
+ effect = "Allow"
+
+ principals {
+ type = "AWS"
+ identifiers = [module.client_transform_filter_lambda.iam_role_arn]
+ }
+
+ actions = [
+ "s3:GetObject",
+ ]
+
+ resources = [
+ "${module.client_config_bucket.arn}/*",
+ ]
+ }
+
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ actions = [
+ "s3:*",
+ ]
+
+ resources = [
+ module.client_config_bucket.arn,
+ "${module.client_config_bucket.arn}/*"
+ ]
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
diff --git a/lambdas/client-transform-filter-lambda/jest.config.ts b/lambdas/client-transform-filter-lambda/jest.config.ts
index f88e727..4cec36d 100644
--- a/lambdas/client-transform-filter-lambda/jest.config.ts
+++ b/lambdas/client-transform-filter-lambda/jest.config.ts
@@ -55,6 +55,9 @@ const utilsJestConfig = {
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
"zod-validators.ts",
],
+
+ // Mirror tsconfig's baseUrl: "src" - automatically resolves non-relative imports
+ modulePaths: ["/src"],
};
export default utilsJestConfig;
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/models/status-transition-event.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/models/status-transition-event.test.ts
new file mode 100644
index 0000000..19f1244
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/models/status-transition-event.test.ts
@@ -0,0 +1,13 @@
+import { EventTypes } from "models/status-transition-event";
+
+// coverage purposes
+describe("EventTypes", () => {
+ it("should match the expected event type values", () => {
+ expect(EventTypes).toEqual({
+ MESSAGE_STATUS_TRANSITIONED:
+ "uk.nhs.notify.client-callbacks.message.status.transitioned.v1",
+ CHANNEL_STATUS_TRANSITIONED:
+ "uk.nhs.notify.client-callbacks.channel.status.transitioned.v1",
+ });
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/models/channel-status-data.ts b/lambdas/client-transform-filter-lambda/src/models/channel-status-data.ts
new file mode 100644
index 0000000..d0d11e6
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/channel-status-data.ts
@@ -0,0 +1,23 @@
+/**
+ * Channel-level status transition event data.
+ */
+import type { Channel } from "models/channel-types";
+import type { ChannelStatus, SupplierStatus } from "models/status-types";
+
+export interface ChannelStatusData {
+ messageId: string;
+ messageReference: string;
+ channel: Channel;
+ channelStatus: ChannelStatus;
+ channelStatusDescription?: string;
+ channelFailureReasonCode?: string;
+ supplierStatus: SupplierStatus;
+ cascadeType: "primary" | "secondary";
+ cascadeOrder: number;
+ timestamp: string;
+ retryCount: number;
+
+ clientId: string;
+ previousChannelStatus?: ChannelStatus;
+ previousSupplierStatus?: SupplierStatus;
+}
diff --git a/lambdas/client-transform-filter-lambda/src/models/channel-types.ts b/lambdas/client-transform-filter-lambda/src/models/channel-types.ts
new file mode 100644
index 0000000..8844c7f
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/channel-types.ts
@@ -0,0 +1,4 @@
+/**
+ * Communication channel types
+ */
+export type Channel = "NHSAPP" | "EMAIL" | "SMS" | "LETTER";
diff --git a/lambdas/client-transform-filter-lambda/src/models/client-callback-payload.ts b/lambdas/client-transform-filter-lambda/src/models/client-callback-payload.ts
new file mode 100644
index 0000000..0aaddd2
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/client-callback-payload.ts
@@ -0,0 +1,59 @@
+/**
+ * Message/Channel Status Callback payload delivered to client webhooks.
+ */
+
+import type { RoutingPlan } from "models/routing-plan";
+import type { Channel } from "models/channel-types";
+import type {
+ ChannelStatus,
+ MessageStatus,
+ SupplierStatus,
+} from "models/status-types";
+
+export type ClientChannel = Lowercase;
+export type ClientMessageStatus = Lowercase;
+export type ClientChannelStatus = Lowercase;
+export type ClientSupplierStatus = Lowercase;
+
+export interface ClientCallbackPayload {
+ data: CallbackItem[];
+}
+
+export interface CallbackItem {
+ type: "MessageStatus" | "ChannelStatus";
+ attributes: MessageStatusAttributes | ChannelStatusAttributes;
+ links: {
+ message: string;
+ };
+ meta: {
+ idempotencyKey: string;
+ };
+}
+
+export interface MessageStatusAttributes {
+ messageId: string;
+ messageReference: string;
+ messageStatus: ClientMessageStatus;
+ messageStatusDescription?: string;
+ messageFailureReasonCode?: string;
+ channels: {
+ type: ClientChannel;
+ channelStatus: ClientChannelStatus;
+ }[];
+ timestamp: string;
+ routingPlan: RoutingPlan;
+}
+
+export interface ChannelStatusAttributes {
+ messageId: string;
+ messageReference: string;
+ cascadeType: "primary" | "secondary";
+ cascadeOrder: number;
+ channel: ClientChannel;
+ channelStatus: ClientChannelStatus;
+ channelStatusDescription?: string;
+ channelFailureReasonCode?: string;
+ supplierStatus: ClientSupplierStatus;
+ timestamp: string;
+ retryCount: number;
+}
diff --git a/lambdas/client-transform-filter-lambda/src/models/client-config.ts b/lambdas/client-transform-filter-lambda/src/models/client-config.ts
new file mode 100644
index 0000000..59c7086
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/client-config.ts
@@ -0,0 +1,49 @@
+/**
+ * Client callback subscription configuration.
+ * Array of subscription rules (one per event type/channel combination).
+ */
+
+export type ClientSubscriptionConfiguration = (
+ | MessageStatusSubscriptionConfiguration
+ | ChannelStatusSubscriptionConfiguration
+)[];
+
+interface SubscriptionConfigurationBase {
+ Name: string;
+ ClientId: string;
+ Description: string;
+ EventSource: string;
+ EventDetail: string;
+ Targets: {
+ Type: "API";
+ TargetId: string;
+ Name: string;
+ InputTransformer: {
+ InputPaths: string;
+ InputHeaders: {
+ "x-hmac-sha256-signature": string;
+ };
+ };
+ InvocationEndpoint: string;
+ InvocationMethod: "POST";
+ InvocationRateLimit: number;
+ APIKey: {
+ HeaderName: string;
+ HeaderValue: string;
+ };
+ }[];
+}
+
+export interface MessageStatusSubscriptionConfiguration
+ extends SubscriptionConfigurationBase {
+ SubscriptionType: "MessageStatus";
+ Statuses: string[];
+}
+
+export interface ChannelStatusSubscriptionConfiguration
+ extends SubscriptionConfigurationBase {
+ SubscriptionType: "ChannelStatus";
+ ChannelType: string;
+ ChannelStatuses: string[];
+ SupplierStatuses: string[];
+}
diff --git a/lambdas/client-transform-filter-lambda/src/models/message-status-data.ts b/lambdas/client-transform-filter-lambda/src/models/message-status-data.ts
new file mode 100644
index 0000000..84c3673
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/message-status-data.ts
@@ -0,0 +1,23 @@
+/**
+ * Message-level status transition event data.
+ */
+import type { RoutingPlan } from "models/routing-plan";
+import type { Channel } from "models/channel-types";
+import type { MessageStatus } from "models/status-types";
+
+export interface MessageStatusData {
+ messageId: string;
+ messageReference: string;
+ messageStatus: MessageStatus;
+ messageStatusDescription?: string;
+ messageFailureReasonCode?: string;
+ channels: {
+ type: Channel;
+ channelStatus: string;
+ }[];
+ timestamp: string;
+ routingPlan: RoutingPlan;
+
+ clientId: string;
+ previousMessageStatus?: MessageStatus;
+}
diff --git a/lambdas/client-transform-filter-lambda/src/models/routing-plan.ts b/lambdas/client-transform-filter-lambda/src/models/routing-plan.ts
new file mode 100644
index 0000000..07f348a
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/routing-plan.ts
@@ -0,0 +1,6 @@
+export interface RoutingPlan {
+ id: string;
+ name: string;
+ version: string;
+ createdDate: string;
+}
diff --git a/lambdas/client-transform-filter-lambda/src/models/status-transition-event.ts b/lambdas/client-transform-filter-lambda/src/models/status-transition-event.ts
new file mode 100644
index 0000000..43efd3f
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/status-transition-event.ts
@@ -0,0 +1,36 @@
+import type { MessageStatusData } from "models/message-status-data";
+import type { ChannelStatusData } from "models/channel-status-data";
+
+/**
+ * Status transition event following CloudEvents 2025-11-draft standard.
+ * Represents a status transition notification published by Core domain to the Shared Event Bus.
+ *
+ * @template T - The type of data payload (MessageStatusData or ChannelStatusData)
+ */
+export interface StatusTransitionEvent<
+ T = MessageStatusData | ChannelStatusData,
+> {
+ specversion: string;
+ id: string;
+ source: string;
+ subject: string;
+ type: string;
+ time: string;
+ recordedtime: string;
+ datacontenttype: string;
+ dataschema: string;
+ traceparent: string;
+
+ data: T;
+}
+
+export const EventTypes = {
+ MESSAGE_STATUS_TRANSITIONED:
+ "uk.nhs.notify.client-callbacks.message.status.transitioned.v1",
+ CHANNEL_STATUS_TRANSITIONED:
+ "uk.nhs.notify.client-callbacks.channel.status.transitioned.v1",
+} as const;
+
+export { type MessageStatusData } from "./message-status-data";
+
+export { type ChannelStatusData } from "models/channel-status-data";
diff --git a/lambdas/client-transform-filter-lambda/src/models/status-types.ts b/lambdas/client-transform-filter-lambda/src/models/status-types.ts
new file mode 100644
index 0000000..a4d104f
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/models/status-types.ts
@@ -0,0 +1,40 @@
+/**
+ * Message-level statuses
+ */
+export type MessageStatus =
+ | "CREATED"
+ | "PENDING_ENRICHMENT"
+ | "ENRICHED"
+ | "SENDING"
+ | "DELIVERED"
+ | "FAILED";
+
+/**
+ * Channel-level statuses
+ */
+export type ChannelStatus =
+ | "CREATED"
+ | "SENDING"
+ | "DELIVERED"
+ | "FAILED"
+ | "SKIPPED";
+
+/**
+ * Supplier-reported statuses
+ */
+export type SupplierStatus =
+ | "DELIVERED"
+ | "READ"
+ | "NOTIFICATION_ATTEMPTED"
+ | "UNNOTIFIED"
+ | "REJECTED"
+ | "NOTIFIED"
+ | "RECEIVED"
+ | "PERMANENT_FAILURE"
+ | "TEMPORARY_FAILURE"
+ | "TECHNICAL_FAILURE"
+ | "ACCEPTED"
+ | "CANCELLED"
+ | "PENDING_VIRUS_CHECK"
+ | "VALIDATION_FAILED"
+ | "UNKNOWN";
diff --git a/lambdas/client-transform-filter-lambda/tsconfig.json b/lambdas/client-transform-filter-lambda/tsconfig.json
index 9e05f63..bbff7bf 100644
--- a/lambdas/client-transform-filter-lambda/tsconfig.json
+++ b/lambdas/client-transform-filter-lambda/tsconfig.json
@@ -1,4 +1,7 @@
{
+ "compilerOptions": {
+ "baseUrl": "src"
+ },
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*",
diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties
index 9fbc7fc..ae7c364 100644
--- a/scripts/config/sonar-scanner.properties
+++ b/scripts/config/sonar-scanner.properties
@@ -3,12 +3,7 @@
sonar.host.url=https://sonarcloud.io
sonar.qualitygate.wait=true
sonar.sourceEncoding=UTF-8
-sonar.sources=lambdas/example-lambda
-sonar.tests=tests/, lambdas/example-lambda/src/__tests__
-sonar.exclusions=lambdas/*/src/__tests__/**/*
sonar.terraform.provider.aws.version=5.54.1
sonar.cpd.exclusions=**.test.*
-sonar.coverage.exclusions=tests/, **/*.dev.*, lambdas/**/src/__tests__, utils/utils/src/zod-validators.ts ,**/jest.config.ts,scripts/**/*
-
-#sonar.python.coverage.reportPaths=.coverage/coverage.xml
+sonar.coverage.exclusions=tests/**, lambdas/**/src/__tests__/**
sonar.javascript.lcov.reportPaths=lcov.info