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