diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md
index 1ef7b2c65..b1cdec30c 100644
--- a/infrastructure/terraform/modules/backend-api/README.md
+++ b/infrastructure/terraform/modules/backend-api/README.md
@@ -39,6 +39,7 @@ No requirements.
|------|--------|---------|
| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
| [count\_routing\_configs\_lambda](#module\_count\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
+| [create\_routing\_config\_lambda](#module\_create\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
| [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
| [delete\_template\_lambda](#module\_delete\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
| [get\_client\_lambda](#module\_get\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a |
diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf
index db31b3e2e..26d13cd2b 100644
--- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf
+++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf
@@ -52,6 +52,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
module.upload_letter_template_lambda.function_arn,
module.count_routing_configs_lambda.function_arn,
module.create_template_lambda.function_arn,
+ module.create_routing_config_lambda.function_arn,
module.delete_template_lambda.function_arn,
module.get_client_lambda.function_arn,
module.get_routing_config_lambda.function_arn,
diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf
index 368c2fafd..cf46795f3 100644
--- a/infrastructure/terraform/modules/backend-api/locals.tf
+++ b/infrastructure/terraform/modules/backend-api/locals.tf
@@ -16,6 +16,7 @@ locals {
AWS_REGION = var.region
COUNT_ROUTING_CONFIGS_LAMBDA_ARN = module.count_routing_configs_lambda.function_arn
CREATE_LAMBDA_ARN = module.create_template_lambda.function_arn
+ CREATE_ROUTING_CONFIG_LAMBDA_ARN = module.create_routing_config_lambda.function_arn
DELETE_LAMBDA_ARN = module.delete_template_lambda.function_arn
GET_CLIENT_LAMBDA_ARN = module.get_client_lambda.function_arn
GET_LAMBDA_ARN = module.get_template_lambda.function_arn
diff --git a/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf b/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf
new file mode 100644
index 000000000..e45bf0f04
--- /dev/null
+++ b/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf
@@ -0,0 +1,68 @@
+module "create_routing_config_lambda" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip"
+
+ project = var.project
+ environment = var.environment
+ component = var.component
+ aws_account_id = var.aws_account_id
+ region = var.region
+
+ kms_key_arn = var.kms_key_arn
+
+ function_name = "create-routing-config"
+
+ function_module_name = "create-routing-config"
+ handler_function_name = "handler"
+ description = "Create Routing Config API endpoint"
+
+ memory = 512
+ timeout = 3
+ runtime = "nodejs20.x"
+
+ log_retention_in_days = var.log_retention_in_days
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.create_routing_config_lambda_policy.json
+ }
+
+ lambda_env_vars = local.backend_lambda_environment_variables
+ function_s3_bucket = var.function_s3_bucket
+ function_code_base_path = local.lambdas_dir
+ function_code_dir = "backend-api/dist/create-routing-config"
+
+ send_to_firehose = var.send_to_firehose
+ log_destination_arn = var.log_destination_arn
+ log_subscription_role_arn = var.log_subscription_role_arn
+}
+
+data "aws_iam_policy_document" "create_routing_config_lambda_policy" {
+ statement {
+ sid = "AllowDynamoAccess"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:PutItem",
+ ]
+
+ resources = [
+ aws_dynamodb_table.routing_configuration.arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowKMSAccess"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:DescribeKey",
+ "kms:Encrypt",
+ "kms:GenerateDataKey*",
+ "kms:ReEncrypt*",
+ ]
+
+ resources = [
+ var.kms_key_arn
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json
index 1599f8905..d048be38d 100644
--- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json
+++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json
@@ -297,7 +297,7 @@
"ClientConfiguration": {
"properties": {
"campaignId": {
- "deprecared": true,
+ "deprecated": true,
"type": "string"
},
"campaignIds": {
@@ -399,6 +399,35 @@
],
"type": "object"
},
+ "CreateUpdateRoutingConfig": {
+ "properties": {
+ "campaignId": {
+ "type": "string"
+ },
+ "cascade": {
+ "items": {
+ "$ref": "#/components/schemas/CascadeItem"
+ },
+ "type": "array"
+ },
+ "cascadeGroupOverrides": {
+ "items": {
+ "$ref": "#/components/schemas/CascadeGroup"
+ },
+ "type": "array"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "campaignId",
+ "cascadeGroupOverrides",
+ "cascade",
+ "name"
+ ],
+ "type": "object"
+ },
"CreateUpdateTemplate": {
"allOf": [
{
@@ -617,9 +646,6 @@
"format": "date-time",
"type": "string"
},
- "createdBy": {
- "type": "string"
- },
"id": {
"format": "uuid",
"type": "string"
@@ -627,18 +653,12 @@
"name": {
"type": "string"
},
- "owner": {
- "type": "string"
- },
"status": {
"$ref": "#/components/schemas/RoutingConfigStatus"
},
"updatedAt": {
"format": "date-time",
"type": "string"
- },
- "updatedBy": {
- "type": "string"
}
},
"required": [
@@ -647,14 +667,10 @@
"cascade",
"clientId",
"createdAt",
- "createdBy",
- "displayCategory",
"id",
"name",
- "owner",
"status",
- "updatedAt",
- "updatedBy"
+ "updatedAt"
],
"type": "object"
},
@@ -1011,6 +1027,71 @@
}
}
},
+ "/v1/routing-configuration": {
+ "post": {
+ "description": "Create a routing configuration",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateUpdateRoutingConfig"
+ }
+ }
+ },
+ "description": "Routing configuration to create",
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RoutingConfigSuccess"
+ }
+ }
+ },
+ "description": "201 response",
+ "headers": {
+ "Content-Type": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Failure"
+ }
+ }
+ },
+ "description": "Error"
+ }
+ },
+ "security": [
+ {
+ "authorizer": []
+ }
+ ],
+ "summary": "Create a routing configuration by ID",
+ "x-amazon-apigateway-integration": {
+ "contentHandling": "CONVERT_TO_TEXT",
+ "credentials": "${APIG_EXECUTION_ROLE_ARN}",
+ "httpMethod": "POST",
+ "passthroughBehavior": "WHEN_NO_TEMPLATES",
+ "responses": {
+ ".*": {
+ "statusCode": "200"
+ }
+ },
+ "timeoutInMillis": 29000,
+ "type": "AWS_PROXY",
+ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${CREATE_ROUTING_CONFIG_LAMBDA_ARN}/invocations"
+ }
+ }
+ },
"/v1/routing-configuration/{routingConfigId}": {
"get": {
"description": "Get a routing configuration by ID",
diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md
index a4417a5ad..d4e2c64dc 100644
--- a/lambdas/backend-api/README.md
+++ b/lambdas/backend-api/README.md
@@ -16,16 +16,10 @@ Setup an authenticated AWS terminal and run
./scripts/sandbox_auth.sh
```
-Grab the `AccessToken` from `sandbox_cognito_auth_token.json`
+Grab the `AccessToken` and `api_base_url` from `sandbox_cognito_auth_token.json`
```bash
-SANDBOX_TOKEN=$(jq -r .AccessToken sandbox_cognito_auth_token.json)
-```
-
-Set APIG stage. The following extracts the value from `sandbox_tf_outputs.tf`. Values resemble `https://cc5p8d4l3b.execute-api.eu-west-2.amazonaws.com/main`
-
-```bash
-APIG_STAGE=$(jq -r .api_base_url.value ./sandbox_tf_outputs.json)
+SANDBOX_TOKEN=$(jq -r .AccessToken sandbox_cognito_auth_token.json) && APIG_STAGE=$(jq -r .api_base_url.value ./sandbox_tf_outputs.json)
```
### GET - /v1/template/:templateId - Get a single template by id
@@ -153,7 +147,26 @@ curl --location "${APIG_STAGE}/v1/routing-configuration/${ROUTING_CONFIG_ID}" \
### GET - /v1/routing-configurations - List routing configurations
```bash
-curl --location "${APIG_STAGE}/v1/routing-configurations \
+curl --location "${APIG_STAGE}/v1/routing-configurations" \
--header 'Accept: application/json' \
--header "Authorization: $SANDBOX_TOKEN"
```
+
+### POST - /v1/routing-configuration - Create a routing configuration
+
+```bash
+curl -X POST --location "${APIG_STAGE}/v1/routing-configuration" \
+--header 'Content-Type: application/json' \
+--header 'Accept: application/json' \
+--header "Authorization: $SANDBOX_TOKEN" \
+--data '{
+ "campaignId": "campaign",
+ "cascade": [{
+ "cascadeGroups": ["standard"],
+ "channel": "EMAIL",
+ "channelType": "primary",
+ "defaultTemplateId": "email_id"
+ }],
+ "cascadeGroupOverrides": [{ "name": "standard" }],
+ "name": "RC name"
+}'
diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh
index 21ec3a323..044127dc2 100755
--- a/lambdas/backend-api/build.sh
+++ b/lambdas/backend-api/build.sh
@@ -16,14 +16,15 @@ npx esbuild \
--external:pdfjs-dist \
src/templates/copy-scanned-object-to-internal.ts \
src/templates/count-routing-configs.ts \
+ src/templates/create-routing-config.ts \
src/templates/create.ts \
- src/templates/delete.ts \
src/templates/delete-failed-scanned-object.ts \
- src/templates/get.ts \
+ src/templates/delete.ts \
src/templates/get-client.ts \
src/templates/get-routing-config.ts \
- src/templates/list.ts \
+ src/templates/get.ts \
src/templates/list-routing-configs.ts \
+ src/templates/list.ts \
src/templates/process-proof.ts \
src/templates/proof.ts \
src/templates/set-letter-upload-virus-scan-status.ts \
diff --git a/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts
index 156b28233..813b86ebf 100644
--- a/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts
@@ -81,7 +81,7 @@ describe('CountRoutingConfigs handler', () => {
});
expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith(
- 'nhs-notify-client-id',
+ { userId: 'sub', clientId: 'nhs-notify-client-id' },
{ status: 'DRAFT' }
);
});
@@ -110,7 +110,7 @@ describe('CountRoutingConfigs handler', () => {
});
expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith(
- 'nhs-notify-client-id',
+ { userId: 'sub', clientId: 'nhs-notify-client-id' },
{ status: 'COMPLETED' }
);
});
diff --git a/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts b/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts
new file mode 100644
index 000000000..52b8d7628
--- /dev/null
+++ b/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts
@@ -0,0 +1,180 @@
+import type { APIGatewayProxyEvent, Context } from 'aws-lambda';
+import { mock } from 'jest-mock-extended';
+import {
+ CreateUpdateRoutingConfig,
+ RoutingConfig,
+} from 'nhs-notify-backend-client';
+import { createHandler } from '@backend-api/templates/api/create-routing-config';
+import type { RoutingConfigClient } from '@backend-api/templates/app/routing-config-client';
+
+function setup() {
+ const routingConfigClient = mock();
+ const mocks = { routingConfigClient };
+ const handler = createHandler(mocks);
+
+ return { handler, mocks };
+}
+
+describe('Create Routing Config Handler', () => {
+ beforeEach(jest.resetAllMocks);
+
+ test.each([
+ ['undefined', undefined],
+ ['missing user', { clientId: 'client-id', user: undefined }],
+ ['missing client', { clientId: undefined, user: 'user-id' }],
+ ])(
+ 'should return 400 - Invalid request when requestContext is %s',
+ async (_, ctx) => {
+ const { handler, mocks } = setup();
+
+ const event = mock({
+ requestContext: { authorizer: ctx },
+ body: JSON.stringify({}),
+ });
+
+ const result = await handler(event, mock(), jest.fn());
+
+ expect(result).toEqual({
+ statusCode: 400,
+ body: JSON.stringify({
+ statusCode: 400,
+ technicalMessage: 'Invalid request',
+ }),
+ });
+
+ expect(
+ mocks.routingConfigClient.createRoutingConfig
+ ).not.toHaveBeenCalled();
+ }
+ );
+
+ test('should return 400 - Invalid request when no body', async () => {
+ const { handler, mocks } = setup();
+
+ mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({
+ error: {
+ errorMeta: {
+ code: 400,
+ description: 'Validation failed',
+ details: {
+ templateType: 'Invalid input: expected string, received undefined',
+ },
+ },
+ },
+ data: undefined,
+ });
+
+ const event = mock({
+ requestContext: {
+ authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' },
+ },
+ body: undefined,
+ });
+
+ const result = await handler(event, mock(), jest.fn());
+
+ expect(result).toEqual({
+ statusCode: 400,
+ body: JSON.stringify({
+ statusCode: 400,
+ technicalMessage: 'Validation failed',
+ details: {
+ templateType: 'Invalid input: expected string, received undefined',
+ },
+ }),
+ });
+
+ expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith(
+ {},
+ { userId: 'sub', clientId: 'nhs-notify-client-id' }
+ );
+ });
+
+ test('should return error when creating routing config fails', async () => {
+ const { handler, mocks } = setup();
+
+ mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({
+ error: {
+ errorMeta: {
+ code: 500,
+ description: 'Internal server error',
+ },
+ },
+ });
+
+ const event = mock({
+ requestContext: {
+ authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' },
+ },
+ body: JSON.stringify({ id: 1 }),
+ });
+
+ const result = await handler(event, mock(), jest.fn());
+
+ expect(result).toEqual({
+ statusCode: 500,
+ body: JSON.stringify({
+ statusCode: 500,
+ technicalMessage: 'Internal server error',
+ }),
+ });
+
+ expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith(
+ { id: 1 },
+ { userId: 'sub', clientId: 'nhs-notify-client-id' }
+ );
+ });
+
+ test('should return created routing config', async () => {
+ const { handler, mocks } = setup();
+
+ const create: CreateUpdateRoutingConfig = {
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: 'apptemplate',
+ },
+ ],
+ cascadeGroupOverrides: [{ name: 'standard' }],
+ name: 'app RC',
+ campaignId: 'campaign',
+ };
+
+ const response: RoutingConfig = {
+ ...create,
+ id: 'id',
+ status: 'DRAFT',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ clientId: 'nhs-notify-client-id',
+ };
+
+ mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({
+ data: response,
+ });
+
+ const event = mock({
+ requestContext: {
+ authorizer: { user: 'sub', clientId: 'notify-client-id' },
+ },
+ body: JSON.stringify(create),
+ });
+
+ const result = await handler(event, mock(), jest.fn());
+
+ expect(result).toEqual({
+ statusCode: 201,
+ body: JSON.stringify({ statusCode: 201, data: response }),
+ });
+
+ expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith(
+ create,
+ {
+ userId: 'sub',
+ clientId: 'notify-client-id',
+ }
+ );
+ });
+});
diff --git a/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts b/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts
index 17b6d3c2e..621bf1bed 100644
--- a/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts
@@ -106,7 +106,7 @@ describe('GetRoutingConfig handler', () => {
expect(mocks.routingConfigClient.getRoutingConfig).toHaveBeenCalledWith(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ { userId: 'sub', clientId: 'nhs-notify-client-id' }
);
});
@@ -135,7 +135,7 @@ describe('GetRoutingConfig handler', () => {
expect(mocks.routingConfigClient.getRoutingConfig).toHaveBeenCalledWith(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ { userId: 'sub', clientId: 'nhs-notify-client-id' }
);
});
});
diff --git a/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts b/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts
index 88ff60286..4cbc2d639 100644
--- a/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts
@@ -82,7 +82,7 @@ describe('ListRoutingConfig handler', () => {
});
expect(mocks.routingConfigClient.listRoutingConfigs).toHaveBeenCalledWith(
- 'nhs-notify-client-id',
+ { clientId: 'nhs-notify-client-id', userId: 'sub' },
{ status: 'DRAFT' }
);
});
@@ -92,12 +92,10 @@ describe('ListRoutingConfig handler', () => {
const list = [
makeRoutingConfig({
- owner: 'nhs-notify-client-id',
clientId: 'nhs-notify-client-id',
status: 'COMPLETED',
}),
makeRoutingConfig({
- owner: 'nhs-notify-client-id',
clientId: 'nhs-notify-client-id',
status: 'COMPLETED',
}),
@@ -124,7 +122,7 @@ describe('ListRoutingConfig handler', () => {
});
expect(mocks.routingConfigClient.listRoutingConfigs).toHaveBeenCalledWith(
- 'nhs-notify-client-id',
+ { clientId: 'nhs-notify-client-id', userId: 'sub' },
{ status: 'COMPLETED' }
);
});
diff --git a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts
index 7891ea859..c0f724a91 100644
--- a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts
@@ -3,10 +3,19 @@ import type { RoutingConfigRepository } from '@backend-api/templates/infra/routi
import { RoutingConfigQuery } from '@backend-api/templates/infra/routing-config-repository/query';
import { RoutingConfigClient } from '@backend-api/templates/app/routing-config-client';
import { routingConfig } from '../fixtures/routing-config';
+import {
+ CreateUpdateRoutingConfig,
+ RoutingConfig,
+} from 'nhs-notify-backend-client';
+
+const user = { userId: 'userid', clientId: 'nhs-notify-client-id' };
function setup() {
const repo = mock();
- const mocks = { routingConfigRepository: repo };
+
+ const mocks = {
+ routingConfigRepository: repo,
+ };
const client = new RoutingConfigClient(repo);
@@ -19,6 +28,7 @@ function mockQuery() {
excludeStatus: jest.fn().mockReturnThis(),
});
}
+
describe('RoutingConfigClient', () => {
describe('getRoutingConfig', () => {
test('returns the routing config from the repository', async () => {
@@ -30,7 +40,7 @@ describe('RoutingConfigClient', () => {
const result = await client.getRoutingConfig(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user
);
expect(result).toEqual({
@@ -39,7 +49,7 @@ describe('RoutingConfigClient', () => {
expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user.clientId
);
});
@@ -54,7 +64,7 @@ describe('RoutingConfigClient', () => {
const result = await client.getRoutingConfig(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user
);
expect(result).toEqual({
@@ -65,7 +75,7 @@ describe('RoutingConfigClient', () => {
expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user.clientId
);
});
@@ -78,7 +88,7 @@ describe('RoutingConfigClient', () => {
const result = await client.getRoutingConfig(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user
);
expect(result).toEqual({
@@ -89,7 +99,7 @@ describe('RoutingConfigClient', () => {
expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith(
'3690d344-731f-4f60-9047-2c63c96623a2',
- 'nhs-notify-client-id'
+ user.clientId
);
});
});
@@ -104,7 +114,7 @@ describe('RoutingConfigClient', () => {
query.list.mockResolvedValueOnce({ data: [routingConfig] });
- const result = await client.listRoutingConfigs('nhs-notify-client-id');
+ const result = await client.listRoutingConfigs(user);
expect(result).toEqual({ data: [routingConfig] });
@@ -118,7 +128,7 @@ describe('RoutingConfigClient', () => {
it('validates status filter parameter', async () => {
const { client, mocks } = setup();
- const result = await client.listRoutingConfigs('nhs-notify-client-id', {
+ const result = await client.listRoutingConfigs(user, {
status: 'INVALID',
});
@@ -146,7 +156,7 @@ describe('RoutingConfigClient', () => {
query.list.mockResolvedValueOnce({ data: [routingConfig] });
- const result = await client.listRoutingConfigs('nhs-notify-client-id', {
+ const result = await client.listRoutingConfigs(user, {
status: 'DRAFT',
});
@@ -170,12 +180,12 @@ describe('RoutingConfigClient', () => {
query.count.mockResolvedValueOnce({ data: { count: 3 } });
- const result = await client.countRoutingConfigs('nhs-notify-client-id');
+ const result = await client.countRoutingConfigs(user);
expect(result).toEqual({ data: { count: 3 } });
expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith(
- 'nhs-notify-client-id'
+ user.clientId
);
expect(query.excludeStatus).toHaveBeenCalledWith('DELETED');
expect(query.status).not.toHaveBeenCalled();
@@ -184,7 +194,7 @@ describe('RoutingConfigClient', () => {
it('validates status filter parameter', async () => {
const { client, mocks } = setup();
- const result = await client.countRoutingConfigs('nhs-notify-client-id', {
+ const result = await client.countRoutingConfigs(user, {
status: 'INVALID',
});
@@ -212,17 +222,141 @@ describe('RoutingConfigClient', () => {
query.count.mockResolvedValueOnce({ data: { count: 18 } });
- const result = await client.countRoutingConfigs('nhs-notify-client-id', {
+ const result = await client.countRoutingConfigs(user, {
status: 'DRAFT',
});
expect(result).toEqual({ data: { count: 18 } });
expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith(
- 'nhs-notify-client-id'
+ user.clientId
);
expect(query.excludeStatus).toHaveBeenCalledWith('DELETED');
expect(query.status).toHaveBeenCalledWith('DRAFT');
});
});
+
+ describe('createRoutingConfig', () => {
+ test('returns created routing config', async () => {
+ const { client, mocks } = setup();
+
+ const date = new Date();
+
+ const input: CreateUpdateRoutingConfig = {
+ name: 'rc',
+ campaignId: 'campaign',
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: 'sms',
+ },
+ ],
+ cascadeGroupOverrides: [{ name: 'standard' }],
+ };
+
+ const rc: RoutingConfig = {
+ ...input,
+ clientId: user.clientId,
+ createdAt: date.toISOString(),
+ id: 'id',
+ status: 'DRAFT',
+ updatedAt: date.toISOString(),
+ };
+
+ mocks.routingConfigRepository.create.mockResolvedValueOnce({
+ data: rc,
+ });
+
+ const result = await client.createRoutingConfig(input, user);
+
+ expect(mocks.routingConfigRepository.create).toHaveBeenCalledWith(
+ input,
+ user
+ );
+
+ expect(result).toEqual({
+ data: rc,
+ });
+ });
+
+ test('returns 400 error when input is invalid', async () => {
+ const { client, mocks } = setup();
+
+ const result = await client.createRoutingConfig(
+ { a: 1 } as unknown as CreateUpdateRoutingConfig,
+ user
+ );
+
+ expect(mocks.routingConfigRepository.create).not.toHaveBeenCalled();
+
+ expect(result).toEqual({
+ error: {
+ actualError: {
+ fieldErrors: {
+ campaignId: [
+ 'Invalid input: expected string, received undefined',
+ ],
+ cascade: ['Invalid input: expected array, received undefined'],
+ cascadeGroupOverrides: [
+ 'Invalid input: expected array, received undefined',
+ ],
+ name: ['Invalid input: expected string, received undefined'],
+ },
+ formErrors: [],
+ },
+ errorMeta: {
+ code: 400,
+ description: 'Request failed validation',
+ details: {
+ campaignId: 'Invalid input: expected string, received undefined',
+ cascade: 'Invalid input: expected array, received undefined',
+ cascadeGroupOverrides:
+ 'Invalid input: expected array, received undefined',
+ name: 'Invalid input: expected string, received undefined',
+ },
+ },
+ },
+ });
+ });
+
+ test('returns failures from the repository', async () => {
+ const { client, mocks } = setup();
+
+ const input: CreateUpdateRoutingConfig = {
+ name: 'rc',
+ campaignId: 'campaign',
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: 'sms',
+ },
+ ],
+ cascadeGroupOverrides: [{ name: 'standard' }],
+ };
+
+ mocks.routingConfigRepository.create.mockResolvedValueOnce({
+ error: { errorMeta: { code: 500, description: 'ddb err' } },
+ });
+
+ const result = await client.createRoutingConfig(input, user);
+
+ expect(mocks.routingConfigRepository.create).toHaveBeenCalledWith(
+ input,
+ user
+ );
+
+ expect(result).toEqual({
+ error: {
+ errorMeta: {
+ code: 500,
+ description: 'ddb err',
+ },
+ },
+ });
+ });
+ });
});
diff --git a/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts b/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts
index bec2458c3..60fc2fd10 100644
--- a/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts
+++ b/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts
@@ -14,16 +14,15 @@ export const routingConfig: RoutingConfig = {
],
cascadeGroupOverrides: [{ name: 'standard' }],
id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- owner: 'client-1',
status: 'DRAFT',
name: 'Test config',
createdAt: '2025-09-18T15:26:04.338Z',
- createdBy: 'user-1',
updatedAt: '2025-09-18T15:26:04.338Z',
- updatedBy: 'user-1',
};
-export const makeRoutingConfig = (overrides: Partial = {}) => ({
+export const makeRoutingConfig = (
+ overrides: Partial = {}
+): RoutingConfig => ({
...routingConfig,
id: randomUUID(),
createdAt: new Date().toISOString(),
diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts
index 4f394943d..d536ee843 100644
--- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts
@@ -10,11 +10,12 @@ jest.mock('nhs-notify-web-template-management-utils/logger');
const TABLE_NAME = 'routing-config-table-name';
-const owner = '89077697-ca6d-47fc-b233-3281fbd15579';
+const clientId = '89077697-ca6d-47fc-b233-3281fbd15579';
+const clientOwnerKey = `CLIENT#${clientId}`;
-const config1 = makeRoutingConfig({ owner, status: 'DRAFT' });
-const config2 = makeRoutingConfig({ owner, status: 'COMPLETED' });
-const config3 = makeRoutingConfig({ owner, status: 'DELETED' });
+const config1 = makeRoutingConfig({ clientId, status: 'DRAFT' });
+const config2 = makeRoutingConfig({ clientId, status: 'COMPLETED' });
+const config3 = makeRoutingConfig({ clientId, status: 'DELETED' });
function setup() {
const dynamo = mockClient(DynamoDBDocumentClient);
@@ -43,13 +44,13 @@ describe('RoutingConfigRepo#query', () => {
.on(QueryCommand)
.resolvesOnce({
Items: page1,
- LastEvaluatedKey: { owner, id: config2.id },
+ LastEvaluatedKey: { owner: clientOwnerKey, id: config2.id },
})
.resolvesOnce({
Items: page2,
});
- const result = await repo.query(owner).list();
+ const result = await repo.query(clientId).list();
expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2);
expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, {
@@ -59,9 +60,9 @@ describe('RoutingConfigRepo#query', () => {
'#owner': 'owner',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
},
- ExclusiveStartKey: { owner, id: config2.id },
+ ExclusiveStartKey: { owner: clientOwnerKey, id: config2.id },
});
expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, {
TableName: TABLE_NAME,
@@ -70,7 +71,7 @@ describe('RoutingConfigRepo#query', () => {
'#owner': 'owner',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
},
});
@@ -85,7 +86,7 @@ describe('RoutingConfigRepo#query', () => {
});
await repo
- .query(owner)
+ .query(clientId)
.status('COMPLETED', 'DELETED')
.status('DRAFT')
.list();
@@ -100,7 +101,7 @@ describe('RoutingConfigRepo#query', () => {
'#status': 'status',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
':status0': 'COMPLETED',
':status1': 'DELETED',
':status2': 'DRAFT',
@@ -116,7 +117,7 @@ describe('RoutingConfigRepo#query', () => {
});
await repo
- .query(owner)
+ .query(clientId)
.excludeStatus('COMPLETED', 'DELETED')
.excludeStatus('DRAFT')
.list();
@@ -132,7 +133,7 @@ describe('RoutingConfigRepo#query', () => {
'#status': 'status',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
':notStatus0': 'COMPLETED',
':notStatus1': 'DELETED',
':notStatus2': 'DRAFT',
@@ -147,7 +148,11 @@ describe('RoutingConfigRepo#query', () => {
Items: [],
});
- await repo.query(owner).status('DRAFT').excludeStatus('DELETED').list();
+ await repo
+ .query(clientId)
+ .status('DRAFT')
+ .excludeStatus('DELETED')
+ .list();
expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1);
expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, {
@@ -160,7 +165,7 @@ describe('RoutingConfigRepo#query', () => {
'#status': 'status',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
':notStatus0': 'DELETED',
':status0': 'DRAFT',
},
@@ -175,7 +180,7 @@ describe('RoutingConfigRepo#query', () => {
});
await repo
- .query(owner)
+ .query(clientId)
.status('DRAFT')
.status('DRAFT')
.excludeStatus('DELETED')
@@ -193,7 +198,7 @@ describe('RoutingConfigRepo#query', () => {
'#status': 'status',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
':notStatus0': 'DELETED',
':status0': 'DRAFT',
},
@@ -206,12 +211,12 @@ describe('RoutingConfigRepo#query', () => {
mocks.dynamo.on(QueryCommand).resolvesOnce({
Items: [
config1,
- { owner, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' },
+ { owner: clientOwnerKey, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' },
config2,
],
});
- const result = await repo.query(owner).list();
+ const result = await repo.query(clientId).list();
expect(result.data).toEqual([config1, config2]);
});
@@ -221,7 +226,7 @@ describe('RoutingConfigRepo#query', () => {
mocks.dynamo.on(QueryCommand).resolvesOnce({});
- const result = await repo.query(owner).list();
+ const result = await repo.query(clientId).list();
expect(result.data).toEqual([]);
});
@@ -233,7 +238,7 @@ describe('RoutingConfigRepo#query', () => {
mocks.dynamo.on(QueryCommand).rejectsOnce(e);
- const result = await repo.query(owner).list();
+ const result = await repo.query(clientId).list();
expect(result.error).toMatchObject({
actualError: e,
@@ -251,13 +256,13 @@ describe('RoutingConfigRepo#query', () => {
.on(QueryCommand)
.resolvesOnce({
Count: 2,
- LastEvaluatedKey: { owner, id: config2.id },
+ LastEvaluatedKey: { owner: clientOwnerKey, id: config2.id },
})
.resolvesOnce({
Count: 1,
});
- const result = await repo.query(owner).count();
+ const result = await repo.query(clientId).count();
expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2);
expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, {
@@ -267,10 +272,10 @@ describe('RoutingConfigRepo#query', () => {
'#owner': 'owner',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
},
Select: 'COUNT',
- ExclusiveStartKey: { owner, id: config2.id },
+ ExclusiveStartKey: { owner: clientOwnerKey, id: config2.id },
});
expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, {
TableName: TABLE_NAME,
@@ -279,7 +284,7 @@ describe('RoutingConfigRepo#query', () => {
'#owner': 'owner',
},
ExpressionAttributeValues: {
- ':owner': owner,
+ ':owner': clientOwnerKey,
},
Select: 'COUNT',
});
@@ -292,7 +297,7 @@ describe('RoutingConfigRepo#query', () => {
mocks.dynamo.on(QueryCommand).resolvesOnce({});
- const result = await repo.query(owner).count();
+ const result = await repo.query(clientId).count();
expect(result.data).toEqual({ count: 0 });
});
@@ -304,7 +309,7 @@ describe('RoutingConfigRepo#query', () => {
mocks.dynamo.on(QueryCommand).rejectsOnce(e);
- const result = await repo.query(owner).count();
+ const result = await repo.query(clientId).count();
expect(result.error).toMatchObject({
actualError: e,
diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts
index b1ddfbe16..09000ae17 100644
--- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts
+++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts
@@ -1,11 +1,33 @@
-import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
+import {
+ DynamoDBDocumentClient,
+ GetCommand,
+ PutCommand,
+} from '@aws-sdk/lib-dynamodb';
import 'aws-sdk-client-mock-jest';
import { mockClient } from 'aws-sdk-client-mock';
import { ZodError } from 'zod';
import { RoutingConfigRepository } from '@backend-api/templates/infra/routing-config-repository';
import { routingConfig } from '../../fixtures/routing-config';
+import {
+ CreateUpdateRoutingConfig,
+ RoutingConfig,
+} from 'nhs-notify-backend-client';
+import { randomUUID, type UUID } from 'node:crypto';
+
+jest.mock('node:crypto');
+const uuidMock = jest.mocked(randomUUID);
+const generatedId = 'c4846505-5380-4601-a361-f82f650adfee';
+uuidMock.mockReturnValue(generatedId);
+
+const date = new Date(2024, 11, 27);
+
+beforeAll(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(date);
+});
const TABLE_NAME = 'routing-config-table-name';
+const user = { userId: 'user', clientId: 'nhs-notify-client-id' };
function setup() {
const dynamo = mockClient(DynamoDBDocumentClient);
@@ -31,7 +53,7 @@ describe('RoutingConfigRepository', () => {
const result = await repo.get(
'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- 'nhs-notify-client-id'
+ user.clientId
);
expect(result).toEqual({ data: routingConfig });
@@ -40,7 +62,7 @@ describe('RoutingConfigRepository', () => {
TableName: TABLE_NAME,
Key: {
id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- owner: 'nhs-notify-client-id',
+ owner: `CLIENT#${user.clientId}`,
},
});
});
@@ -54,7 +76,7 @@ describe('RoutingConfigRepository', () => {
const result = await repo.get(
'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- 'nhs-notify-client-id'
+ user.clientId
);
expect(result).toEqual({
@@ -67,7 +89,7 @@ describe('RoutingConfigRepository', () => {
TableName: TABLE_NAME,
Key: {
id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- owner: 'nhs-notify-client-id',
+ owner: `CLIENT#${user.clientId}`,
},
});
});
@@ -81,7 +103,7 @@ describe('RoutingConfigRepository', () => {
const result = await repo.get(
'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- 'nhs-notify-client-id'
+ user.clientId
);
expect(result.error).toMatchObject({
@@ -96,7 +118,100 @@ describe('RoutingConfigRepository', () => {
TableName: TABLE_NAME,
Key: {
id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- owner: 'nhs-notify-client-id',
+ owner: `CLIENT#${user.clientId}`,
+ },
+ });
+ });
+ });
+
+ describe('create', () => {
+ const input: CreateUpdateRoutingConfig = {
+ name: 'rc',
+ campaignId: 'campaign',
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: 'sms',
+ },
+ ],
+ cascadeGroupOverrides: [{ name: 'standard' }],
+ };
+
+ const rc: RoutingConfig = {
+ ...input,
+ clientId: user.clientId,
+ createdAt: date.toISOString(),
+ id: generatedId,
+ status: 'DRAFT',
+ updatedAt: date.toISOString(),
+ };
+
+ const putPayload = {
+ ...rc,
+ owner: `CLIENT#${user.clientId}`,
+ createdBY: user.userId,
+ updatedBy: user.userId,
+ };
+
+ test('should create routing config', async () => {
+ const { repo, mocks } = setup();
+
+ mocks.dynamo
+ .on(PutCommand, {
+ TableName: TABLE_NAME,
+ Item: putPayload,
+ })
+ .resolves({});
+
+ const result = await repo.create(input, user);
+
+ expect(result).toEqual({ data: rc });
+ });
+
+ test('returns failure if put fails', async () => {
+ const { repo, mocks } = setup();
+
+ const err = new Error('ddb_err');
+
+ mocks.dynamo.on(PutCommand).rejects(err);
+
+ const result = await repo.create(input, user);
+
+ expect(result).toEqual({
+ error: {
+ actualError: err,
+ errorMeta: {
+ code: 500,
+ description: 'Failed to create routing config',
+ },
+ },
+ });
+ });
+
+ test('returns failure if constructed routing config is invalid', async () => {
+ const { repo } = setup();
+
+ uuidMock.mockReturnValueOnce('not_a_uuid' as UUID);
+
+ const result = await repo.create(input, user);
+
+ expect(result).toEqual({
+ error: {
+ actualError: expect.objectContaining({
+ issues: expect.arrayContaining([
+ expect.objectContaining({
+ path: ['id'],
+ code: 'invalid_format',
+ format: 'uuid',
+ }),
+ ]),
+ }),
+ errorMeta: {
+ code: 500,
+ description: 'Failed to create routing config',
+ },
},
});
});
diff --git a/lambdas/backend-api/src/templates/api/count-routing-configs.ts b/lambdas/backend-api/src/templates/api/count-routing-configs.ts
index db14d1bf8..26e446a5e 100644
--- a/lambdas/backend-api/src/templates/api/count-routing-configs.ts
+++ b/lambdas/backend-api/src/templates/api/count-routing-configs.ts
@@ -15,13 +15,12 @@ export function createHandler({
return apiFailure(400, 'Invalid request');
}
- const log = logger.child({
- clientId,
- userId,
- });
+ const user = { userId, clientId };
+
+ const log = logger.child(user);
const { data, error } = await routingConfigClient.countRoutingConfigs(
- clientId,
+ user,
event.queryStringParameters
);
diff --git a/lambdas/backend-api/src/templates/api/create-routing-config.ts b/lambdas/backend-api/src/templates/api/create-routing-config.ts
new file mode 100644
index 000000000..1577e300f
--- /dev/null
+++ b/lambdas/backend-api/src/templates/api/create-routing-config.ts
@@ -0,0 +1,46 @@
+import type { APIGatewayProxyHandler } from 'aws-lambda';
+import { apiFailure, apiSuccess } from './responses';
+import type { RoutingConfigClient } from '../app/routing-config-client';
+import { logger } from 'nhs-notify-web-template-management-utils/logger';
+
+export function createHandler({
+ routingConfigClient,
+}: {
+ routingConfigClient: RoutingConfigClient;
+}): APIGatewayProxyHandler {
+ return async function handler(event) {
+ const { user: userId, clientId } = event.requestContext.authorizer ?? {};
+
+ if (!clientId || !userId) {
+ return apiFailure(400, 'Invalid request');
+ }
+
+ const payload = JSON.parse(event.body || '{}');
+
+ const user = {
+ clientId,
+ userId,
+ };
+
+ const log = logger.child(user);
+
+ const { data, error } = await routingConfigClient.createRoutingConfig(
+ payload,
+ user
+ );
+
+ if (error) {
+ log
+ .child(error.errorMeta)
+ .error('Failed to create routing configuration', error.actualError);
+
+ return apiFailure(
+ error.errorMeta.code,
+ error.errorMeta.description,
+ error.errorMeta.details
+ );
+ }
+
+ return apiSuccess(201, data);
+ };
+}
diff --git a/lambdas/backend-api/src/templates/api/get-routing-config.ts b/lambdas/backend-api/src/templates/api/get-routing-config.ts
index a2fe48814..04f2b0549 100644
--- a/lambdas/backend-api/src/templates/api/get-routing-config.ts
+++ b/lambdas/backend-api/src/templates/api/get-routing-config.ts
@@ -25,7 +25,7 @@ export function createHandler({
const { data, error } = await routingConfigClient.getRoutingConfig(
routingConfigId,
- clientId
+ { clientId, userId }
);
if (error) {
diff --git a/lambdas/backend-api/src/templates/api/list-routing-configs.ts b/lambdas/backend-api/src/templates/api/list-routing-configs.ts
index e6d52b5bd..cbd9088a1 100644
--- a/lambdas/backend-api/src/templates/api/list-routing-configs.ts
+++ b/lambdas/backend-api/src/templates/api/list-routing-configs.ts
@@ -15,13 +15,15 @@ export function createHandler({
return apiFailure(400, 'Invalid request');
}
- const log = logger.child({
+ const user = {
clientId,
userId,
- });
+ };
+
+ const log = logger.child(user);
const { data, error } = await routingConfigClient.listRoutingConfigs(
- clientId,
+ user,
event.queryStringParameters
);
diff --git a/lambdas/backend-api/src/templates/app/routing-config-client.ts b/lambdas/backend-api/src/templates/app/routing-config-client.ts
index afad79849..8c3834713 100644
--- a/lambdas/backend-api/src/templates/app/routing-config-client.ts
+++ b/lambdas/backend-api/src/templates/app/routing-config-client.ts
@@ -1,24 +1,46 @@
import { failure } from '@backend-api/utils/result';
import {
+ $CreateUpdateRoutingConfig,
$ListRoutingConfigFilters,
ErrorCase,
+ type CreateUpdateRoutingConfig,
type ListRoutingConfigFilters,
type Result,
type RoutingConfig,
} from 'nhs-notify-backend-client';
import { validate } from '@backend-api/utils/validate';
import type { RoutingConfigRepository } from '../infra/routing-config-repository';
+import type { User } from 'nhs-notify-web-template-management-utils';
export class RoutingConfigClient {
constructor(
private readonly routingConfigRepository: RoutingConfigRepository
) {}
+ async createRoutingConfig(
+ routingConfig: CreateUpdateRoutingConfig,
+ user: User
+ ): Promise> {
+ const validationResult = await validate(
+ $CreateUpdateRoutingConfig,
+ routingConfig
+ );
+
+ if (validationResult.error) return validationResult;
+
+ const createResult = await this.routingConfigRepository.create(
+ validationResult.data,
+ user
+ );
+
+ return createResult;
+ }
+
async getRoutingConfig(
id: string,
- owner: string
+ user: User
): Promise> {
- const result = await this.routingConfigRepository.get(id, owner);
+ const result = await this.routingConfigRepository.get(id, user.clientId);
if (result.data?.status === 'DELETED') {
return failure(ErrorCase.NOT_FOUND, 'Routing Config not found');
@@ -28,7 +50,7 @@ export class RoutingConfigClient {
}
async listRoutingConfigs(
- owner: string,
+ user: User,
filters?: unknown
): Promise> {
let parsedFilters: ListRoutingConfigFilters = {};
@@ -44,7 +66,7 @@ export class RoutingConfigClient {
}
const query = this.routingConfigRepository
- .query(owner)
+ .query(user.clientId)
.excludeStatus('DELETED');
if (parsedFilters.status) {
@@ -55,7 +77,7 @@ export class RoutingConfigClient {
}
async countRoutingConfigs(
- owner: string,
+ user: User,
filters?: unknown
): Promise> {
let parsedFilters: ListRoutingConfigFilters = {};
@@ -71,7 +93,7 @@ export class RoutingConfigClient {
}
const query = this.routingConfigRepository
- .query(owner)
+ .query(user.clientId)
.excludeStatus('DELETED');
if (parsedFilters.status) {
diff --git a/lambdas/backend-api/src/templates/create-routing-config.ts b/lambdas/backend-api/src/templates/create-routing-config.ts
new file mode 100644
index 000000000..8b6b97c5f
--- /dev/null
+++ b/lambdas/backend-api/src/templates/create-routing-config.ts
@@ -0,0 +1,4 @@
+import { createHandler } from './api/create-routing-config';
+import { createContainer } from './container';
+
+export const handler = createHandler(createContainer());
diff --git a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts
index 6d7e16524..1d45b4baf 100644
--- a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts
+++ b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts
@@ -1,10 +1,21 @@
-import { GetCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
-import { ApplicationResult, failure, success } from '@backend-api/utils/result';
+import { randomUUID } from 'node:crypto';
+import {
+ GetCommand,
+ PutCommand,
+ type DynamoDBDocumentClient,
+} from '@aws-sdk/lib-dynamodb';
+import {
+ type ApplicationResult,
+ failure,
+ success,
+} from '@backend-api/utils/result';
import {
$RoutingConfig,
+ type CreateUpdateRoutingConfig,
ErrorCase,
type RoutingConfig,
} from 'nhs-notify-backend-client';
+import type { User } from 'nhs-notify-web-template-management-utils';
import { RoutingConfigQuery } from './query';
export class RoutingConfigRepository {
@@ -13,9 +24,44 @@ export class RoutingConfigRepository {
private readonly tableName: string
) {}
+ async create(routingConfigInput: CreateUpdateRoutingConfig, user: User) {
+ const date = new Date().toISOString();
+
+ try {
+ const routingConfig = $RoutingConfig.parse({
+ ...routingConfigInput,
+ clientId: user.clientId,
+ createdAt: date,
+ id: randomUUID(),
+ status: 'DRAFT',
+ updatedAt: date,
+ });
+
+ await this.client.send(
+ new PutCommand({
+ TableName: this.tableName,
+ Item: {
+ ...routingConfig,
+ owner: this.clientOwnerKey(user.clientId),
+ updatedBy: user.userId,
+ createdBy: user.userId,
+ },
+ })
+ );
+
+ return success(routingConfig);
+ } catch (error) {
+ return failure(
+ ErrorCase.INTERNAL,
+ 'Failed to create routing config',
+ error
+ );
+ }
+ }
+
async get(
id: string,
- owner: string
+ clientId: string
): Promise> {
try {
const result = await this.client.send(
@@ -23,7 +69,7 @@ export class RoutingConfigRepository {
TableName: this.tableName,
Key: {
id,
- owner,
+ owner: this.clientOwnerKey(clientId),
},
})
);
@@ -44,7 +90,15 @@ export class RoutingConfigRepository {
}
}
- query(owner: string): RoutingConfigQuery {
- return new RoutingConfigQuery(this.client, this.tableName, owner);
+ query(clientId: string): RoutingConfigQuery {
+ return new RoutingConfigQuery(
+ this.client,
+ this.tableName,
+ this.clientOwnerKey(clientId)
+ );
+ }
+
+ private clientOwnerKey(clientId: string) {
+ return `CLIENT#${clientId}`;
}
}
diff --git a/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap b/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap
index d365c81f5..90419d5a0 100644
--- a/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap
+++ b/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`RoutingConfig schema snapshot full error 1`] = `
+exports[`CreateUpdateRoutingConfig schema snapshot full error 1`] = `
[ZodError: [
{
"expected": "string",
@@ -10,14 +10,6 @@ exports[`RoutingConfig schema snapshot full error 1`] = `
],
"message": "Invalid input: expected string, received undefined"
},
- {
- "expected": "string",
- "code": "invalid_type",
- "path": [
- "clientId"
- ],
- "message": "Invalid input: expected string, received undefined"
- },
{
"expected": "array",
"code": "invalid_type",
@@ -38,29 +30,38 @@ exports[`RoutingConfig schema snapshot full error 1`] = `
"expected": "string",
"code": "invalid_type",
"path": [
- "id"
+ "name"
],
"message": "Invalid input: expected string, received undefined"
- },
+ }
+]]
+`;
+
+exports[`RoutingConfig schema snapshot full error 1`] = `
+[ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
- "owner"
+ "campaignId"
],
"message": "Invalid input: expected string, received undefined"
},
{
- "code": "invalid_value",
- "values": [
- "COMPLETED",
- "DRAFT",
- "DELETED"
+ "expected": "array",
+ "code": "invalid_type",
+ "path": [
+ "cascade"
],
+ "message": "Invalid input: expected array, received undefined"
+ },
+ {
+ "expected": "array",
+ "code": "invalid_type",
"path": [
- "status"
+ "cascadeGroupOverrides"
],
- "message": "Invalid option: expected one of \\"COMPLETED\\"|\\"DRAFT\\"|\\"DELETED\\""
+ "message": "Invalid input: expected array, received undefined"
},
{
"expected": "string",
@@ -74,7 +75,7 @@ exports[`RoutingConfig schema snapshot full error 1`] = `
"expected": "string",
"code": "invalid_type",
"path": [
- "createdAt"
+ "clientId"
],
"message": "Invalid input: expected string, received undefined"
},
@@ -82,15 +83,27 @@ exports[`RoutingConfig schema snapshot full error 1`] = `
"expected": "string",
"code": "invalid_type",
"path": [
- "createdBy"
+ "id"
],
"message": "Invalid input: expected string, received undefined"
},
+ {
+ "code": "invalid_value",
+ "values": [
+ "COMPLETED",
+ "DRAFT",
+ "DELETED"
+ ],
+ "path": [
+ "status"
+ ],
+ "message": "Invalid option: expected one of \\"COMPLETED\\"|\\"DRAFT\\"|\\"DELETED\\""
+ },
{
"expected": "string",
"code": "invalid_type",
"path": [
- "updatedAt"
+ "createdAt"
],
"message": "Invalid input: expected string, received undefined"
},
@@ -98,7 +111,7 @@ exports[`RoutingConfig schema snapshot full error 1`] = `
"expected": "string",
"code": "invalid_type",
"path": [
- "updatedBy"
+ "updatedAt"
],
"message": "Invalid input: expected string, received undefined"
}
diff --git a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts
index 4fefce752..8dc95da7a 100644
--- a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts
+++ b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts
@@ -1,79 +1,84 @@
import {
+ $CreateUpdateRoutingConfig,
$RoutingConfig,
$ListRoutingConfigFilters,
} from '../../schemas/routing-config';
-describe('RoutingConfig schema', () => {
- const base = {
- campaignId: 'campaign-1',
- clientId: 'client-1',
- cascade: [
- {
- cascadeGroups: ['standard'],
- channel: 'LETTER',
- channelType: 'primary',
- defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09',
- },
- ],
- cascadeGroupOverrides: [
- { name: 'standard' },
- { name: 'translations', language: ['ar', 'zh'] },
- { name: 'accessible', accessibleFormat: ['x1', 'x0'] },
- ],
- id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
- owner: 'client-1',
- status: 'DRAFT',
- name: 'Test config',
- createdAt: '2025-09-18T15:26:04.338Z',
- createdBy: 'user-1',
- updatedAt: '2025-09-18T15:26:04.338Z',
- updatedBy: 'user-1',
- };
+const baseInput = {
+ campaignId: 'campaign-1',
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09',
+ },
+ ],
+ cascadeGroupOverrides: [
+ { name: 'standard' },
+ { name: 'translations', language: ['ar', 'zh'] },
+ { name: 'accessible', accessibleFormat: ['x1', 'x0'] },
+ ],
+ name: 'Test config',
+};
+
+const cascadeCondLang = {
+ cascadeGroups: ['translations'],
+ channel: 'LETTER',
+ channelType: 'secondary',
+ conditionalTemplates: [
+ { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' },
+ ],
+};
- const cascadeCondLang = {
- cascadeGroups: ['translations'],
- channel: 'LETTER',
- channelType: 'secondary',
- conditionalTemplates: [
- { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' },
- ],
- };
+const cascadeCondAcc = {
+ cascadeGroups: ['accessible'],
+ channel: 'LETTER',
+ channelType: 'secondary',
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1',
+ templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09',
+ },
+ ],
+};
- const cascadeCondAcc = {
- cascadeGroups: ['accessible'],
- channel: 'LETTER',
- channelType: 'secondary',
- conditionalTemplates: [
- {
- accessibleFormat: 'x1',
- templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09',
- },
- ],
- };
+const baseCreated = {
+ ...baseInput,
+ clientId: 'client-1',
+ id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f',
+ owner: 'CLIENT#client-1',
+ status: 'DRAFT',
+ createdAt: '2025-09-18T15:26:04.338Z',
+ createdBy: 'user-1',
+ updatedAt: '2025-09-18T15:26:04.338Z',
+ updatedBy: 'user-1',
+};
+describe('CreateUpdateRoutingConfig schema', () => {
test('valid minimal with defaultTemplateId cascade item', () => {
- const res = $RoutingConfig.safeParse(base);
+ const res = $CreateUpdateRoutingConfig.safeParse(baseInput);
expect(res.success).toBe(true);
});
test('valid with translations conditional cascade item', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [cascadeCondLang],
});
expect(res.success).toBe(true);
});
test('valid with accessible conditional cascade item', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [cascadeCondAcc],
});
expect(res.success).toBe(true);
});
test('snapshot full error', () => {
- const res = $RoutingConfig.safeParse({});
+ const res = $CreateUpdateRoutingConfig.safeParse({});
expect(res.success).toBe(false);
@@ -81,37 +86,40 @@ describe('RoutingConfig schema', () => {
});
test('cascade must be nonempty', () => {
- const res = $RoutingConfig.safeParse({ ...base, cascade: [] });
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
+ cascade: [],
+ });
expect(res.success).toBe(false);
});
test('cascadeGroupOverrides must be nonempty', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [],
});
expect(res.success).toBe(false);
});
test('translations override languages must be nonempty', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [{ name: 'translations', language: [] }],
});
expect(res.success).toBe(false);
});
test('accessible override accessibleFormat must be nonempty', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [{ name: 'accessible', accessibleFormat: [] }],
});
expect(res.success).toBe(false);
});
test('accessible cascade group must does accept "language"', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [
{ name: 'accessible', accessibleFormat: ['x1'], language: ['ar'] },
],
@@ -120,8 +128,8 @@ describe('RoutingConfig schema', () => {
});
test('translations cascade group does not accept "accessibleFormat"', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [
{ name: 'translations', language: ['ar'], accessibleFormat: ['x1'] },
],
@@ -130,16 +138,16 @@ describe('RoutingConfig schema', () => {
});
test('standard cascade group does not accept extra keys', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [{ name: 'standard', foo: 'bar' }],
});
expect(res.success).toBe(false);
});
test('CascadeItem with both defaultTemplateId and conditionalTemplates is invalid', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [
{
...cascadeCondLang,
@@ -152,8 +160,8 @@ describe('RoutingConfig schema', () => {
});
test('CascadeItem missing both defaultTemplateId and conditionalTemplates is invalid', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [
{
cascadeGroups: ['standard'],
@@ -165,17 +173,9 @@ describe('RoutingConfig schema', () => {
expect(res.success).toBe(false);
});
- test('invalid status', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
- status: 'INVALID_STATUS',
- });
- expect(res.success).toBe(false);
- });
-
test('invalid cascade channel', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [
{
cascadeGroups: ['standard'],
@@ -189,24 +189,24 @@ describe('RoutingConfig schema', () => {
});
test('invalid cascade group language', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [{ name: 'translations', language: ['xx'] }],
});
expect(res.success).toBe(false);
});
test('invalid cascade group accessible format', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascadeGroupOverrides: [{ name: 'accessible', accessibleFormat: ['xx'] }],
});
expect(res.success).toBe(false);
});
test('invalid cascade conditional language', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [
{
cascadeGroups: ['translations'],
@@ -225,8 +225,8 @@ describe('RoutingConfig schema', () => {
});
test('invalid cascade conditional accessible format', () => {
- const res = $RoutingConfig.safeParse({
- ...base,
+ const res = $CreateUpdateRoutingConfig.safeParse({
+ ...baseInput,
cascade: [
{
cascadeGroups: ['accessible'],
@@ -243,10 +243,33 @@ describe('RoutingConfig schema', () => {
});
expect(res.success).toBe(false);
});
+});
+
+describe('RoutingConfig schema', () => {
+ test('valid minimal', () => {
+ const res = $RoutingConfig.safeParse(baseCreated);
+ expect(res.success).toBe(true);
+ });
+
+ test('snapshot full error', () => {
+ const res = $RoutingConfig.safeParse({});
+
+ expect(res.success).toBe(false);
+
+ expect(res.error).toMatchSnapshot();
+ });
+
+ test('invalid status', () => {
+ const res = $RoutingConfig.safeParse({
+ ...baseCreated,
+ status: 'INVALID_STATUS',
+ });
+ expect(res.success).toBe(false);
+ });
test('invalid if id not a uuid', () => {
const res = $RoutingConfig.safeParse({
- ...base,
+ ...baseCreated,
id: 'not-a-uuid',
});
expect(res.success).toBe(false);
diff --git a/lambdas/backend-client/src/schemas/routing-config.ts b/lambdas/backend-client/src/schemas/routing-config.ts
index 9e585c2a6..2de6b9bea 100644
--- a/lambdas/backend-client/src/schemas/routing-config.ts
+++ b/lambdas/backend-client/src/schemas/routing-config.ts
@@ -13,6 +13,7 @@ import type {
ChannelType,
ConditionalTemplateAccessible,
ConditionalTemplateLanguage,
+ CreateUpdateRoutingConfig,
RoutingConfig,
RoutingConfigStatus,
RoutingConfigStatusActive,
@@ -105,6 +106,16 @@ const $CascadeItem = schemaFor()(
z.union([$CascadeItemWithConditional, $CascadeItemWithDefault])
);
+export const $CreateUpdateRoutingConfig =
+ schemaFor()(
+ z.object({
+ campaignId: z.string(),
+ cascade: z.array($CascadeItem).nonempty(),
+ cascadeGroupOverrides: z.array($CascadeGroup).nonempty(),
+ name: z.string(),
+ })
+ );
+
const $RoutingConfigStatus = schemaFor()(
z.enum(ROUTING_CONFIG_STATUS_LIST)
);
@@ -116,17 +127,14 @@ const $RoutingConfigStatusActive = schemaFor()(
export const $RoutingConfig = schemaFor()(
z.object({
campaignId: z.string(),
- clientId: z.string(),
cascade: z.array($CascadeItem).nonempty(),
cascadeGroupOverrides: z.array($CascadeGroup).nonempty(),
+ name: z.string(),
+ clientId: z.string(),
id: z.uuidv4(),
- owner: z.string(),
status: $RoutingConfigStatus,
- name: z.string(),
createdAt: z.string(),
- createdBy: z.string(),
updatedAt: z.string(),
- updatedBy: z.string(),
})
);
diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts
index 6cbc16005..77b99969a 100644
--- a/lambdas/backend-client/src/types/generated/types.gen.ts
+++ b/lambdas/backend-client/src/types/generated/types.gen.ts
@@ -70,6 +70,9 @@ export type Channel = 'EMAIL' | 'LETTER' | 'NHSAPP' | 'SMS';
export type ChannelType = 'primary' | 'secondary';
export type ClientConfiguration = {
+ /**
+ * @deprecated
+ */
campaignId?: string;
campaignIds?: Array;
features: ClientFeatures;
@@ -102,6 +105,13 @@ export type CountSuccess = {
statusCode: number;
};
+export type CreateUpdateRoutingConfig = {
+ campaignId: string;
+ cascade: Array;
+ cascadeGroupOverrides: Array;
+ name: string;
+};
+
export type CreateUpdateTemplate = BaseTemplate &
(SmsProperties | EmailProperties | NhsAppProperties | UploadLetterProperties);
@@ -184,13 +194,10 @@ export type RoutingConfig = {
cascadeGroupOverrides: Array;
clientId: string;
createdAt: string;
- createdBy: string;
id: string;
name: string;
- owner: string;
status: RoutingConfigStatus;
updatedAt: string;
- updatedBy: string;
};
export type RoutingConfigStatus = RoutingConfigStatusActive | 'DELETED';
@@ -308,6 +315,36 @@ export type PostV1LetterTemplateResponses = {
export type PostV1LetterTemplateResponse =
PostV1LetterTemplateResponses[keyof PostV1LetterTemplateResponses];
+export type PostV1RoutingConfigurationData = {
+ /**
+ * Routing configuration to create
+ */
+ body: CreateUpdateRoutingConfig;
+ path?: never;
+ query?: never;
+ url: '/v1/routing-configuration';
+};
+
+export type PostV1RoutingConfigurationErrors = {
+ /**
+ * Error
+ */
+ default: Failure;
+};
+
+export type PostV1RoutingConfigurationError =
+ PostV1RoutingConfigurationErrors[keyof PostV1RoutingConfigurationErrors];
+
+export type PostV1RoutingConfigurationResponses = {
+ /**
+ * 201 response
+ */
+ 201: RoutingConfigSuccess;
+};
+
+export type PostV1RoutingConfigurationResponse =
+ PostV1RoutingConfigurationResponses[keyof PostV1RoutingConfigurationResponses];
+
export type GetV1RoutingConfigurationByRoutingConfigIdData = {
body?: never;
path: {
diff --git a/scripts/sandbox_auth.sh b/scripts/sandbox_auth.sh
index 62b76020f..57396b23e 100755
--- a/scripts/sandbox_auth.sh
+++ b/scripts/sandbox_auth.sh
@@ -15,6 +15,7 @@ password=$2
cognito_user_pool_id=$(jq -r .cognito_user_pool_id.value $root_dir/sandbox_tf_outputs.json)
cognito_user_pool_client_id=$(jq -r .cognito_user_pool_client_id.value $root_dir/sandbox_tf_outputs.json)
+client_ssm_path_prefix=$(jq -r .client_ssm_path_prefix.value $root_dir/sandbox_tf_outputs.json)
set +e # if the user or client doesn't exist, we expect these commands to fail
get_user_command_output=$(aws cognito-idp admin-get-user --user-pool-id "$cognito_user_pool_id" --username "$email" 2>&1)
@@ -36,7 +37,28 @@ if [[ "$get_user_command_exit_code" -ne 0 ]]; then
temp_password=$(gen_temp_password)
- read -p "Enter a Notify Client ID for this user: " notify_client_id
+ read -p "Enter a Notify Client ID for this user, or press enter to generate one: " notify_client_id
+
+ if [ -z "$notify_client_id" ]; then
+ notify_client_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
+ fi
+
+ echo "Generated Client ID: $notify_client_id"
+
+ client_config_param_name="$client_ssm_path_prefix/$notify_client_id"
+
+ client_config_param_value="{ "campaignId": "campaign", "features": { "proofing": true } }"
+
+ if aws ssm get-parameter --name "$client_config_param_name" --with-decryption >/dev/null 2>&1; then
+ echo "Client config parameter already exists: $client_config_param_name"
+ else
+ aws ssm put-parameter \
+ --name "$client_config_param_name" \
+ --value "$client_config_param_value" \
+ --type String
+
+ echo "Created client config parameter: $client_config_param_name"
+ fi
aws cognito-idp admin-create-user \
--user-pool-id "${cognito_user_pool_id}" \
diff --git a/tests/test-team/helpers/db/routing-config-storage-helper.ts b/tests/test-team/helpers/db/routing-config-storage-helper.ts
index 57e427dcc..7290bf8fc 100644
--- a/tests/test-team/helpers/db/routing-config-storage-helper.ts
+++ b/tests/test-team/helpers/db/routing-config-storage-helper.ts
@@ -5,7 +5,7 @@ import {
} from '@aws-sdk/lib-dynamodb';
import type { RoutingConfig } from 'nhs-notify-backend-client';
-type RoutingConfigKey = Pick;
+type RoutingConfigKey = { id: string; clientId: string };
export class RoutingConfigStorageHelper {
private readonly dynamo: DynamoDBDocumentClient = DynamoDBDocumentClient.from(
@@ -14,6 +14,8 @@ export class RoutingConfigStorageHelper {
private seedData: RoutingConfig[] = [];
+ private adHocRoutingConfigKeys: RoutingConfigKey[] = [];
+
/**
* Seed a load of routing configs into the database
*/
@@ -46,9 +48,9 @@ export class RoutingConfigStorageHelper {
*/
public async deleteSeeded() {
await this.delete(
- this.seedData.map(({ id, owner }) => ({
+ this.seedData.map(({ id, clientId }) => ({
id,
- owner,
+ clientId,
}))
);
this.seedData = [];
@@ -63,11 +65,11 @@ export class RoutingConfigStorageHelper {
new BatchWriteCommand({
RequestItems: {
[process.env.ROUTING_CONFIG_TABLE_NAME]: chunk.map(
- ({ id, owner }) => ({
+ ({ id, clientId }) => ({
DeleteRequest: {
Key: {
id,
- owner,
+ owner: this.clientOwnerKey(clientId),
},
},
})
@@ -79,6 +81,21 @@ export class RoutingConfigStorageHelper {
);
}
+ /**
+ * Stores references to routing configs created in tests (not via seeding)
+ */
+ public addAdHocRoutingConfigKey(key: RoutingConfigKey) {
+ this.adHocRoutingConfigKeys.push(key);
+ }
+
+ /**
+ * Delete routing configs referenced by calls to addAdHocRoutingConfigKey from database
+ */
+ async deleteAdHocRoutingConfigs() {
+ await this.delete(this.adHocRoutingConfigKeys);
+ this.adHocRoutingConfigKeys = [];
+ }
+
/**
* Breaks a list into chunks of upto 25 items
*/
@@ -91,4 +108,8 @@ export class RoutingConfigStorageHelper {
return chunks;
}
+
+ private clientOwnerKey(clientId: string) {
+ return `CLIENT#${clientId}`;
+ }
}
diff --git a/tests/test-team/helpers/factories/routing-config-factory.ts b/tests/test-team/helpers/factories/routing-config-factory.ts
index 14056dbd0..0ab556795 100644
--- a/tests/test-team/helpers/factories/routing-config-factory.ts
+++ b/tests/test-team/helpers/factories/routing-config-factory.ts
@@ -1,14 +1,20 @@
import { randomUUID } from 'node:crypto';
-import { RoutingConfig } from 'nhs-notify-backend-client';
+import {
+ CreateUpdateRoutingConfig,
+ RoutingConfig,
+} from 'nhs-notify-backend-client';
+import type {
+ FactoryRoutingConfig,
+ RoutingConfigDbEntry,
+} from '../../helpers/types';
export const RoutingConfigFactory = {
create(
- routingConfig: Partial & Pick
- ): RoutingConfig {
- return {
- id: randomUUID(),
+ user: { userId: string; clientId: string },
+ routingConfig: Partial = {}
+ ): FactoryRoutingConfig {
+ const apiPayload: CreateUpdateRoutingConfig = {
campaignId: 'campaign-1',
- clientId: 'client-1',
cascade: [
{
cascadeGroups: ['standard'],
@@ -18,13 +24,30 @@ export const RoutingConfigFactory = {
},
],
cascadeGroupOverrides: [{ name: 'standard' }],
- updatedBy: 'user-1',
- status: 'DRAFT',
name: 'Test config',
+ ...routingConfig,
+ };
+
+ const apiResponse: RoutingConfig = {
+ id: randomUUID(),
+ clientId: user.clientId,
+ status: 'DRAFT',
createdAt: new Date().toISOString(),
- createdBy: 'user-1',
updatedAt: new Date().toISOString(),
- ...routingConfig,
+ ...apiPayload,
+ };
+
+ const dbEntry: RoutingConfigDbEntry = {
+ owner: `CLIENT#${user.clientId}`,
+ createdBy: user.userId,
+ updatedBy: user.userId,
+ ...apiResponse,
+ };
+
+ return {
+ apiPayload,
+ apiResponse,
+ dbEntry,
};
},
};
diff --git a/tests/test-team/helpers/types.ts b/tests/test-team/helpers/types.ts
index fa0e5feeb..92114596d 100644
--- a/tests/test-team/helpers/types.ts
+++ b/tests/test-team/helpers/types.ts
@@ -1,3 +1,8 @@
+import type {
+ CreateUpdateRoutingConfig,
+ RoutingConfig,
+} from 'nhs-notify-backend-client';
+
export const templateTypeDisplayMappings: Record = {
NHS_APP: 'NHS App message',
SMS: 'Text message (SMS)',
@@ -63,3 +68,15 @@ export type Template = TypeSpecificProperties & {
version: number;
proofingEnabled?: boolean;
};
+
+export type RoutingConfigDbEntry = RoutingConfig & {
+ owner: string;
+ updatedBy: string;
+ createdBy: string;
+};
+
+export type FactoryRoutingConfig = {
+ apiPayload: CreateUpdateRoutingConfig;
+ apiResponse: RoutingConfig;
+ dbEntry: RoutingConfigDbEntry;
+};
diff --git a/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts
index aecb5efb0..fc0600a41 100644
--- a/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts
+++ b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts
@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test';
-import type { RoutingConfig } from 'nhs-notify-backend-client';
import {
createAuthHelper,
type TestUser,
@@ -7,6 +6,7 @@ import {
} from '../helpers/auth/cognito-auth-helper';
import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper';
import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory';
+import type { FactoryRoutingConfig } from 'helpers/types';
test.describe('GET /v1/routing-configurations/count', () => {
const authHelper = createAuthHelper();
@@ -14,43 +14,29 @@ test.describe('GET /v1/routing-configurations/count', () => {
let user1: TestUser;
let user2: TestUser;
let userSharedClient: TestUser;
- let draftRoutingConfig: RoutingConfig;
- let completedRoutingConfig: RoutingConfig;
- let deletedRoutingConfig: RoutingConfig;
+ let draftRoutingConfig: FactoryRoutingConfig;
+ let completedRoutingConfig: FactoryRoutingConfig;
+ let deletedRoutingConfig: FactoryRoutingConfig;
test.beforeAll(async () => {
user1 = await authHelper.getTestUser(testUsers.User1.userId);
user2 = await authHelper.getTestUser(testUsers.User2.userId);
userSharedClient = await authHelper.getTestUser(testUsers.User7.userId);
- draftRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
- clientId: user1.clientId,
- status: 'DRAFT',
- createdBy: user1.userId,
- updatedBy: user1.userId,
- });
+ draftRoutingConfig = RoutingConfigFactory.create(user1);
- completedRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
- clientId: user1.clientId,
+ completedRoutingConfig = RoutingConfigFactory.create(user1, {
status: 'COMPLETED',
- createdBy: user1.userId,
- updatedBy: user1.userId,
});
- deletedRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
- clientId: user1.clientId,
+ deletedRoutingConfig = RoutingConfigFactory.create(user1, {
status: 'DELETED',
- createdBy: user1.userId,
- updatedBy: user1.userId,
});
await storageHelper.seed([
- draftRoutingConfig,
- completedRoutingConfig,
- deletedRoutingConfig,
+ draftRoutingConfig.dbEntry,
+ completedRoutingConfig.dbEntry,
+ deletedRoutingConfig.dbEntry,
]);
});
diff --git a/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts
new file mode 100644
index 000000000..995a3302c
--- /dev/null
+++ b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from '@playwright/test';
+import {
+ createAuthHelper,
+ type TestUser,
+ testUsers,
+} from '../helpers/auth/cognito-auth-helper';
+import {
+ isoDateRegExp,
+ uuidRegExp,
+} from 'nhs-notify-web-template-management-test-helper-utils';
+import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper';
+import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory';
+
+test.describe('POST /v1/routing-configuration', () => {
+ const authHelper = createAuthHelper();
+ const storageHelper = new RoutingConfigStorageHelper();
+ let user1: TestUser;
+
+ test.beforeAll(async () => {
+ user1 = await authHelper.getTestUser(testUsers.User1.userId);
+ });
+
+ test.afterAll(async () => {
+ await storageHelper.deleteAdHocRoutingConfigs();
+ });
+
+ test('returns 201 if routing config input is valid', async ({ request }) => {
+ const payload = RoutingConfigFactory.create(user1).apiPayload;
+
+ const start = new Date();
+
+ const response = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`,
+ {
+ headers: {
+ Authorization: await user1.getAccessToken(),
+ },
+ data: payload,
+ }
+ );
+
+ expect(response.status()).toBe(201);
+
+ const created = await response.json();
+
+ storageHelper.addAdHocRoutingConfigKey({
+ id: created.data.id,
+ clientId: user1.clientId,
+ });
+
+ expect(created).toEqual({
+ statusCode: 201,
+ data: {
+ clientId: user1.clientId,
+ campaignId: payload.campaignId,
+ cascade: payload.cascade,
+ cascadeGroupOverrides: payload.cascadeGroupOverrides,
+ createdAt: expect.stringMatching(isoDateRegExp),
+ name: payload.name,
+ id: expect.stringMatching(uuidRegExp),
+ status: 'DRAFT',
+ updatedAt: expect.stringMatching(isoDateRegExp),
+ },
+ });
+
+ expect(created.data.createdAt).toBeDateRoughlyBetween([start, new Date()]);
+ expect(created.data.createdAt).toEqual(created.data.updatedAt);
+ });
+
+ test('returns 401 if no auth token', async ({ request }) => {
+ const response = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`
+ );
+ expect(response.status()).toBe(401);
+ expect(await response.json()).toEqual({
+ message: 'Unauthorized',
+ });
+ });
+
+ test('returns 400 if no body on request', async ({ request }) => {
+ const response = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`,
+ {
+ headers: {
+ Authorization: await user1.getAccessToken(),
+ },
+ }
+ );
+
+ expect(response.status()).toBe(400);
+
+ expect(await response.json()).toEqual({
+ statusCode: 400,
+ technicalMessage: 'Request failed validation',
+ details: {
+ campaignId: 'Invalid input: expected string, received undefined',
+ cascade: 'Invalid input: expected array, received undefined',
+ cascadeGroupOverrides:
+ 'Invalid input: expected array, received undefined',
+ name: 'Invalid input: expected string, received undefined',
+ },
+ });
+ });
+
+ test('returns 400 if routing config has invalid field (name)', async ({
+ request,
+ }) => {
+ const response = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`,
+ {
+ headers: {
+ Authorization: await user1.getAccessToken(),
+ },
+ data: RoutingConfigFactory.create(user1, {
+ name: 700 as unknown as string,
+ }).apiPayload,
+ }
+ );
+
+ expect(response.status()).toBe(400);
+
+ expect(await response.json()).toEqual({
+ statusCode: 400,
+ technicalMessage: 'Request failed validation',
+ details: {
+ name: 'Invalid input: expected string, received number',
+ },
+ });
+ });
+
+ test('ignores status if given - routing config cannot be completed at create time', async ({
+ request,
+ }) => {
+ const response = await request.post(
+ `${process.env.API_BASE_URL}/v1/routing-configuration`,
+ {
+ headers: {
+ Authorization: await user1.getAccessToken(),
+ },
+ data: RoutingConfigFactory.create(user1, {
+ status: 'COMPLETED',
+ }).apiPayload,
+ }
+ );
+
+ expect(response.status()).toBe(201);
+
+ const created = await response.json();
+
+ storageHelper.addAdHocRoutingConfigKey({
+ id: created.data.id,
+ clientId: user1.clientId,
+ });
+
+ expect(created.data.status).toEqual('DRAFT');
+ });
+});
diff --git a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts
index 35ecd8e37..3c8224ec3 100644
--- a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts
+++ b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { test, expect } from '@playwright/test';
-import type { RoutingConfig } from 'nhs-notify-backend-client';
import {
createAuthHelper,
type TestUser,
@@ -8,6 +7,7 @@ import {
} from '../helpers/auth/cognito-auth-helper';
import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper';
import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory';
+import type { FactoryRoutingConfig } from 'helpers/types';
test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
const authHelper = createAuthHelper();
@@ -15,24 +15,24 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
let user1: TestUser;
let user2: TestUser;
let userSharedClient: TestUser;
- let routingConfig: RoutingConfig;
- let deletedRoutingConfig: RoutingConfig;
+ let routingConfig: FactoryRoutingConfig;
+ let deletedRoutingConfig: FactoryRoutingConfig;
test.beforeAll(async () => {
user1 = await authHelper.getTestUser(testUsers.User1.userId);
user2 = await authHelper.getTestUser(testUsers.User2.userId);
userSharedClient = await authHelper.getTestUser(testUsers.User7.userId);
- routingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
- });
+ routingConfig = RoutingConfigFactory.create(user1);
- deletedRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
+ deletedRoutingConfig = RoutingConfigFactory.create(user1, {
status: 'DELETED',
});
- await storageHelper.seed([routingConfig, deletedRoutingConfig]);
+ await storageHelper.seed([
+ routingConfig.dbEntry,
+ deletedRoutingConfig.dbEntry,
+ ]);
});
test.afterAll(async () => {
@@ -54,7 +54,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
}) => {
// exercise: make the GET request to retrieve the routing config
const response = await request.get(
- `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`,
+ `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`,
{
headers: {
Authorization: await user1.getAccessToken(),
@@ -66,7 +66,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({
statusCode: 200,
- data: routingConfig,
+ data: routingConfig.apiResponse,
});
});
@@ -92,7 +92,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
}) => {
// exercise: make the GET request to retrieve the routing config as user2
const response = await request.get(
- `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`,
+ `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`,
{
headers: {
Authorization: await user2.getAccessToken(),
@@ -113,7 +113,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
}) => {
// exercise: make the GET request to retrieve the deleted routing config
const response = await request.get(
- `${process.env.API_BASE_URL}/v1/routing-configuration/${deletedRoutingConfig.id}`,
+ `${process.env.API_BASE_URL}/v1/routing-configuration/${deletedRoutingConfig.dbEntry.id}`,
{
headers: {
Authorization: await user1.getAccessToken(),
@@ -133,7 +133,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
request,
}) => {
const response = await request.get(
- `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`,
+ `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`,
{
headers: {
Authorization: await userSharedClient.getAccessToken(),
@@ -144,7 +144,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => {
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({
statusCode: 200,
- data: routingConfig,
+ data: routingConfig.apiResponse,
});
});
});
diff --git a/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts b/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts
index 3501d44bc..0651ab32c 100644
--- a/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts
+++ b/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts
@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test';
-import type { RoutingConfig } from 'nhs-notify-backend-client';
import {
createAuthHelper,
type TestUser,
@@ -7,6 +6,7 @@ import {
} from '../helpers/auth/cognito-auth-helper';
import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper';
import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory';
+import type { FactoryRoutingConfig } from 'helpers/types';
test.describe('GET /v1/routing-configurations', () => {
const authHelper = createAuthHelper();
@@ -14,43 +14,34 @@ test.describe('GET /v1/routing-configurations', () => {
let user1: TestUser;
let user2: TestUser;
let userSharedClient: TestUser;
- let draftRoutingConfig: RoutingConfig;
- let completedRoutingConfig: RoutingConfig;
- let deletedRoutingConfig: RoutingConfig;
+ let draftRoutingConfig: FactoryRoutingConfig;
+ let completedRoutingConfig: FactoryRoutingConfig;
+ let deletedRoutingConfig: FactoryRoutingConfig;
test.beforeAll(async () => {
user1 = await authHelper.getTestUser(testUsers.User1.userId);
user2 = await authHelper.getTestUser(testUsers.User2.userId);
userSharedClient = await authHelper.getTestUser(testUsers.User7.userId);
- draftRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
+ draftRoutingConfig = RoutingConfigFactory.create(user1, {
clientId: user1.clientId,
status: 'DRAFT',
- createdBy: user1.userId,
- updatedBy: user1.userId,
});
- completedRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
+ completedRoutingConfig = RoutingConfigFactory.create(user1, {
clientId: user1.clientId,
status: 'COMPLETED',
- createdBy: user1.userId,
- updatedBy: user1.userId,
});
- deletedRoutingConfig = RoutingConfigFactory.create({
- owner: user1.clientId,
+ deletedRoutingConfig = RoutingConfigFactory.create(user1, {
clientId: user1.clientId,
status: 'DELETED',
- createdBy: user1.userId,
- updatedBy: user1.userId,
});
await storageHelper.seed([
- draftRoutingConfig,
- completedRoutingConfig,
- deletedRoutingConfig,
+ draftRoutingConfig.dbEntry,
+ completedRoutingConfig.dbEntry,
+ deletedRoutingConfig.dbEntry,
]);
});
@@ -89,8 +80,8 @@ test.describe('GET /v1/routing-configurations', () => {
expect(body).toEqual({
statusCode: 200,
data: expect.arrayContaining([
- draftRoutingConfig,
- completedRoutingConfig,
+ draftRoutingConfig.apiResponse,
+ completedRoutingConfig.apiResponse,
]),
});
@@ -142,8 +133,8 @@ test.describe('GET /v1/routing-configurations', () => {
expect(body).toEqual({
statusCode: 200,
data: expect.arrayContaining([
- draftRoutingConfig,
- completedRoutingConfig,
+ draftRoutingConfig.apiResponse,
+ completedRoutingConfig.apiResponse,
]),
});
@@ -169,7 +160,7 @@ test.describe('GET /v1/routing-configurations', () => {
expect(body).toEqual({
statusCode: 200,
- data: [draftRoutingConfig],
+ data: [draftRoutingConfig.apiResponse],
});
});
@@ -192,7 +183,7 @@ test.describe('GET /v1/routing-configurations', () => {
expect(body).toEqual({
statusCode: 200,
- data: [completedRoutingConfig],
+ data: [completedRoutingConfig.apiResponse],
});
});