From 2f4f16e52565baf10b579000a7cae559ad0eff46 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 13 Mar 2026 12:53:40 +0000 Subject: [PATCH 01/34] init --- .../terraform/modules/backend-api/README.md | 1 + .../iam_role_api_gateway_execution_role.tf | 1 + .../terraform/modules/backend-api/locals.tf | 1 + .../module_approve_template_lambda.tf | 78 +++++++++++++++++++ .../modules/backend-api/spec.tmpl.json | 74 ++++++++++++++++++ package-lock.json | 29 +++---- packages/types/src/index.ts | 5 ++ packages/types/src/types.gen.ts | 38 +++++++++ 8 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index b3ffa7f60..4ea274cc0 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -39,6 +39,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| +| [approve\_template\_lambda](#module\_approve\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v3.0.6/terraform-lambda.zip | n/a | | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/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.29/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.29/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 2f104c7b8..4fff63849 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 @@ -48,6 +48,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { ] resources = [ + module.approve_template_lambda.function_arn, module.authorizer_lambda.function_arn, module.upload_docx_letter_template_lambda.function_arn, module.upload_letter_template_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index c103d0221..2eceb9f3b 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -12,6 +12,7 @@ locals { openapi_spec = templatefile("${path.module}/spec.tmpl.json", { APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn + APPROVE_TEMPLATE_LAMBDA_ARN = module.approve_template_lambda.function_arn AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn AWS_REGION = var.region COUNT_ROUTING_CONFIGS_LAMBDA_ARN = module.count_routing_configs_lambda.function_arn diff --git a/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf b/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf new file mode 100644 index 000000000..03325cb27 --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf @@ -0,0 +1,78 @@ +module "approve_template_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v3.0.6/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 = "approve-template" + + function_module_name = "approve-template" + handler_function_name = "handler" + description = "Approve a template" + + memory = 2048 + timeout = 20 + runtime = "nodejs22.x" + + log_retention_in_days = var.log_retention_in_days + iam_policy_document = { + body = data.aws_iam_policy_document.approve_template_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/approve-template" + + 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" "approve_template_lambda_policy" { + statement { + sid = "AllowDynamoAccess" + effect = "Allow" + + actions = [ + "dynamodb:UpdateItem", + ] + + resources = [ + aws_dynamodb_table.templates.arn, + ] + } + + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } + + statement { + sid = "AllowSSMParameterRead" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + ] + + resources = [local.client_ssm_path_pattern] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 43431032e..4804fbf9e 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -2335,6 +2335,80 @@ } } }, + "/v1/template/{templateId}/approve": { + "patch": { + "description": "Approve a template by Id", + "parameters": [ + { + "description": "ID of template to approve", + "in": "path", + "name": "templateId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Lock number of the current version of the template", + "in": "header", + "name": "X-Lock-Number", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateSuccess" + } + } + }, + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Approve a template", + "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/${APPROVE_TEMPLATE_LAMBDA_ARN}/invocations" + } + } + }, "/v1/template/{templateId}/generate-letter-proof": { "post": { "description": "Generate a personalised letter proof", diff --git a/package-lock.json b/package-lock.json index 41c037ffa..e3729f3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7719,13 +7719,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", - "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz", + "integrity": "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", @@ -7826,9 +7826,9 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", - "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "version": "3.996.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz", + "integrity": "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -7842,7 +7842,7 @@ "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.5", + "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", @@ -7932,15 +7932,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", - "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz", + "integrity": "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -7970,9 +7971,9 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index edcd3833c..83f1ae77d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -92,6 +92,11 @@ export type { PatchV1RoutingConfigurationByRoutingConfigIdSubmitErrors, PatchV1RoutingConfigurationByRoutingConfigIdSubmitResponse, PatchV1RoutingConfigurationByRoutingConfigIdSubmitResponses, + PatchV1TemplateByTemplateIdApproveData, + PatchV1TemplateByTemplateIdApproveError, + PatchV1TemplateByTemplateIdApproveErrors, + PatchV1TemplateByTemplateIdApproveResponse, + PatchV1TemplateByTemplateIdApproveResponses, PatchV1TemplateByTemplateIdData, PatchV1TemplateByTemplateIdError, PatchV1TemplateByTemplateIdErrors, diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index c68317165..0bb2f5ecb 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -898,6 +898,44 @@ export type PutV1TemplateByTemplateIdResponses = { export type PutV1TemplateByTemplateIdResponse = PutV1TemplateByTemplateIdResponses[keyof PutV1TemplateByTemplateIdResponses]; +export type PatchV1TemplateByTemplateIdApproveData = { + body?: never; + headers: { + /** + * Lock number of the current version of the template + */ + 'X-Lock-Number': number; + }; + path: { + /** + * ID of template to approve + */ + templateId: string; + }; + query?: never; + url: '/v1/template/{templateId}/approve'; +}; + +export type PatchV1TemplateByTemplateIdApproveErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PatchV1TemplateByTemplateIdApproveError = + PatchV1TemplateByTemplateIdApproveErrors[keyof PatchV1TemplateByTemplateIdApproveErrors]; + +export type PatchV1TemplateByTemplateIdApproveResponses = { + /** + * 200 response + */ + 200: TemplateSuccess; +}; + +export type PatchV1TemplateByTemplateIdApproveResponse = + PatchV1TemplateByTemplateIdApproveResponses[keyof PatchV1TemplateByTemplateIdApproveResponses]; + export type PostV1TemplateByTemplateIdGenerateLetterProofData = { /** * Template to update From 7f7d2324c084fdd28885285e4b4232af84a06629 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 13 Mar 2026 14:23:34 +0000 Subject: [PATCH 02/34] skeleton page --- .../[templateId]/page.tsx | 90 +++++++++++++++++++ .../[templateId]/server-action.ts | 28 ++++++ frontend/src/content/content.ts | 5 ++ frontend/src/middleware.ts | 1 + lambdas/backend-api/build.sh | 1 + .../backend-api/src/api/approve-template.ts | 39 ++++++++ .../backend-api/src/app/template-client.ts | 68 ++++++++++++++ lambdas/backend-api/src/approve-template.ts | 4 + .../infra/template-repository/repository.ts | 57 ++++++++++++ .../backend-client/src/template-api-client.ts | 30 +++++++ 10 files changed, 323 insertions(+) create mode 100644 frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx create mode 100644 frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts create mode 100644 lambdas/backend-api/src/api/approve-template.ts create mode 100644 lambdas/backend-api/src/approve-template.ts diff --git a/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx b/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx new file mode 100644 index 000000000..cd4c15d92 --- /dev/null +++ b/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx @@ -0,0 +1,90 @@ +'use server'; + +import { Metadata } from 'next'; +import { redirect, RedirectType } from 'next/navigation'; +import { + TemplatePageProps, + validateLetterTemplate, +} from 'nhs-notify-web-template-management-utils'; +import { getTemplate } from '@utils/form-actions'; +import content from '@content/content'; +import { $LockNumber } from 'nhs-notify-backend-client'; +import { NHSNotifyContainer } from '@layouts/container/container'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; +import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import Link from 'next/link'; +import { reviewAndApproveLetterTemplateAction } from './server-action'; +import PreviewTemplateDetailsAuthoringLetter from '@molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter'; +import { LetterRenderIframe } from '@molecules/LetterRender/LetterRenderIframe'; + +const { pageTitle } = content.pages.reviewAndApproveLetterTemplate; + +export async function generateMetadata(): Promise { + return { + title: pageTitle, + }; +} + +const ReviewAndApproveLetterTemplatePage = async (props: TemplatePageProps) => { + const { templateId } = await props.params; + + const searchParams = await props.searchParams; + + const lockNumberResult = $LockNumber.safeParse(searchParams?.lockNumber); + + if (!lockNumberResult.success) { + return redirect( + `/preview-letter-template/${templateId}`, + RedirectType.replace + ); + } + + const template = await getTemplate(templateId); + + const validatedTemplate = validateLetterTemplate(template); + + if (!validatedTemplate || validatedTemplate.letterVersion !== 'AUTHORING') { + return redirect('/invalid-template', RedirectType.replace); + } + + return ( + + + + + + + + + + + + +

+ {'backLinkText'} +

+
+
+
+ ); +}; + +export default ReviewAndApproveLetterTemplatePage; diff --git a/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts b/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts new file mode 100644 index 000000000..6db9610c9 --- /dev/null +++ b/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts @@ -0,0 +1,28 @@ +'use server'; + +import { z } from 'zod/v4'; +import { $LockNumber } from 'nhs-notify-backend-client'; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import { redirect } from 'next/navigation'; + +const $FormSchema = z.object({ + templateId: z.string().nonempty(), + lockNumber: $LockNumber, +}); + +export async function reviewAndApproveLetterTemplateAction( + _: FormState, + form: FormData +): Promise { + const result = $FormSchema.safeParse(Object.fromEntries(form.entries())); + + if (result.error) { + return { + errorState: z.flattenError(result.error), + }; + } + + const { templateId, lockNumber } = result.data; + + redirect(`/submit-letter-template/${templateId}?lockNumber=${lockNumber}`); +} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index c4425c79e..f2ed954ed 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1791,6 +1791,10 @@ const reviewAndMoveToProduction = { }, }; +const reviewAndApproveLetterTemplate = { + pageTitle: generatePageTitle('Review and approve letter template'), +}; + const editTemplateNamePage = { pageTitle: generatePageTitle('Edit template name'), form: { @@ -1899,6 +1903,7 @@ const content = { previewOtherLanguageLetterTemplate, previewStandardEnglishLetterTemplate, previewSubmittedLetterTemplate, + reviewAndApproveLetterTemplate, reviewAndMoveToProduction, submitLetterTemplate: submitLetterTemplatePage, uploadDocxLetterTemplatePage, diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 09d175b13..b0d9f9201 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -52,6 +52,7 @@ const protectedPaths = [ /^\/preview-submitted-nhs-app-template\/[^/]+$/, /^\/preview-submitted-text-message-template\/[^/]+$/, /^\/preview-text-message-template\/[^/]+$/, + /^\/review-and-approve-letter-template\/[^/]+$/, /^\/request-proof-of-template\/[^/]+$/, /^\/submit-email-template\/[^/]+$/, /^\/submit-letter-template\/[^/]+$/, diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index 78742e451..c5b3c527f 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -14,6 +14,7 @@ npx esbuild \ --entry-names=[name]/[name] \ --outdir=dist \ --external:pdfjs-dist \ + src/approve-template.ts \ src/copy-scanned-object-to-internal.ts \ src/count-routing-configs.ts \ src/create-routing-config.ts \ diff --git a/lambdas/backend-api/src/api/approve-template.ts b/lambdas/backend-api/src/api/approve-template.ts new file mode 100644 index 000000000..a0dd6aec7 --- /dev/null +++ b/lambdas/backend-api/src/api/approve-template.ts @@ -0,0 +1,39 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { apiFailure, apiSuccess } from '@backend-api/api/responses'; +import type { TemplateClient } from '@backend-api/app/template-client'; +import { toHeaders } from '@backend-api/utils/headers'; + +export function createHandler({ + templateClient, +}: { + templateClient: TemplateClient; +}): APIGatewayProxyHandler { + return async function (event) { + const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; + + const templateId = event.pathParameters?.templateId; + + if (!internalUserId || !templateId || !clientId) { + return apiFailure(400, 'Invalid request'); + } + + const { data, error } = await templateClient.approveTemplate( + templateId, + { + internalUserId, + clientId, + }, + toHeaders(event.headers).get('X-Lock-Number') ?? '' + ); + + if (error) { + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(200, data); + }; +} diff --git a/lambdas/backend-api/src/app/template-client.ts b/lambdas/backend-api/src/app/template-client.ts index aedf92835..6fd585192 100644 --- a/lambdas/backend-api/src/app/template-client.ts +++ b/lambdas/backend-api/src/app/template-client.ts @@ -558,6 +558,74 @@ export class TemplateClient { return success(templateDTO); } + async approveTemplate( + templateId: string, + user: User, + lockNumber: string + ): Promise> { + const log = this.logger.child({ templateId, user }); + + const lockNumberValidation = $LockNumber.safeParse(lockNumber); + + if (!lockNumberValidation.success) { + log.error( + 'Lock number failed validation', + z.treeifyError(lockNumberValidation.error) + ); + + return failure( + ErrorCase.VALIDATION_FAILED, + 'Invalid lock number provided' + ); + } + + const { data: template, error: templateError } = await this.getTemplate( + templateId, + user + ); + + if (templateError) { + log + .child(templateError.errorMeta) + .error('Failed to get template', templateError.actualError); + + return { error: templateError }; + } + + if (template.templateType !== 'LETTER') { + log + .child({ templateType: template.templateType }) + .error('Only letters may be approved'); + + return failure(ErrorCase.VALIDATION_FAILED, 'Unexpected non-letter'); + } + + const updateResult = await this.templateRepository.approveLetterTemplate( + templateId, + user, + lockNumberValidation.data + ); + + if (updateResult.error) { + log + .child(updateResult.error.errorMeta) + .error( + 'Failed to save template to the database', + updateResult.error.actualError + ); + + return updateResult; + } + + const templateDTO = this.mapDatabaseObjectToDTO(updateResult.data); + + if (!templateDTO) { + return failure(ErrorCase.INTERNAL, 'Error retrieving template'); + } + + return success(templateDTO); + } + async submitTemplate( templateId: string, user: User, diff --git a/lambdas/backend-api/src/approve-template.ts b/lambdas/backend-api/src/approve-template.ts new file mode 100644 index 000000000..223df3391 --- /dev/null +++ b/lambdas/backend-api/src/approve-template.ts @@ -0,0 +1,4 @@ +import { createHandler } from './api/approve-template'; +import { templatesContainer } from './container/templates'; + +export const handler = createHandler(templatesContainer()); diff --git a/lambdas/backend-api/src/infra/template-repository/repository.ts b/lambdas/backend-api/src/infra/template-repository/repository.ts index 40fca7953..c06db6206 100644 --- a/lambdas/backend-api/src/infra/template-repository/repository.ts +++ b/lambdas/backend-api/src/infra/template-repository/repository.ts @@ -322,6 +322,63 @@ export class TemplateRepository { } } + async approveLetterTemplate( + templateId: string, + user: User, + lockNumber: number + ) { + const update = new TemplateUpdateBuilder( + this.templatesTableName, + user.clientId, + templateId, + { + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + ReturnValues: 'ALL_NEW', + } + ) + .setStatus('PROOF_APPROVED') + .expectStatus('NOT_YET_SUBMITTED') + .setUpdatedByUserAt(this.internalUserKey(user)) + .expectTemplateType('LETTER') + .expectClientId(user.clientId) + .expectLetterVersion('AUTHORING') + .expectLockNumber(lockNumber) + .incrementLockNumber() + .build(); + + try { + const response = await this.client.send(new UpdateCommand(update)); + return success(response.Attributes as DatabaseTemplate); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + if (!error.Item || error.Item.templateStatus.S === 'DELETED') { + return failure(ErrorCase.NOT_FOUND, 'Template not found'); + } + + const oldItem = unmarshall(error.Item); + + if (oldItem.lockNumber !== lockNumber) { + return failure( + ErrorCase.CONFLICT, + 'Lock number mismatch - Template has been modified since last read', + error + ); + } + + return failure( + ErrorCase.VALIDATION_FAILED, + 'Template cannot be approved', + error + ); + } + + return failure(ErrorCase.INTERNAL, 'Failed to update template', error); + } + } + + /** + * @deprecated - PDF letter only + */ async approveProof(templateId: string, user: User, lockNumber: number) { const updateExpression = ['#templateStatus = :newStatus']; diff --git a/lambdas/backend-client/src/template-api-client.ts b/lambdas/backend-client/src/template-api-client.ts index 1a16e593b..8393c7a48 100644 --- a/lambdas/backend-client/src/template-api-client.ts +++ b/lambdas/backend-client/src/template-api-client.ts @@ -202,6 +202,36 @@ export const templateApiClient = { }; }, + async approveTemplate( + templateId: string, + owner: string, + lockNumber: number + ): Promise> { + const response = await catchAxiosError( + httpClient.patch( + `/v1/template/${templateId}/approve`, + undefined, + { + headers: { + 'Content-Type': 'application/json', + Authorization: owner, + 'X-Lock-Number': String(lockNumber), + }, + } + ) + ); + + if (response.error) { + return { + error: response.error, + }; + } + + return { + data: response.data.data, + }; + }, + async submitTemplate( templateId: string, owner: string, From 9141f7c14bd795118acc689b3edd53ce6b947095 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Fri, 13 Mar 2026 16:15:52 +0000 Subject: [PATCH 03/34] tests --- .../page.test.tsx | 210 ++++++++++ .../server-action.test.ts | 103 +++++ .../src/__tests__/utils/form-actions.test.ts | 77 ++++ ...ewAndApproveLetterTemplatePage.module.scss | 4 + .../[templateId]/page.tsx | 98 +++-- .../[templateId]/server-action.ts | 5 +- .../LetterRender/LetterRenderIframe.tsx | 9 +- .../PreviewTemplateDetailsAuthoringLetter.tsx | 4 + frontend/src/content/content.ts | 6 + frontend/src/utils/form-actions.ts | 24 ++ .../terraform/modules/backend-api/README.md | 2 +- .../module_approve_template_lambda.tf | 2 +- .../__tests__/api/approve-template.test.ts | 258 ++++++++++++ .../src/__tests__/app/template-client.test.ts | 167 ++++++++ .../template-repository/repository.test.ts | 142 +++++++ .../backend-api/src/app/template-client.ts | 21 - .../src/__tests__/template-api-client.test.ts | 52 +++ tests/test-team/pages/letter/index.ts | 1 + ...review-and-approve-letter-template-page.ts | 25 ++ .../approve-template.api.spec.ts | 374 ++++++++++++++++++ ...ove-letter-template-page.component.spec.ts | 136 +++++++ 21 files changed, 1666 insertions(+), 54 deletions(-) create mode 100644 frontend/src/__tests__/app/review-and-approve-letter-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/review-and-approve-letter-template/server-action.test.ts create mode 100644 frontend/src/app/review-and-approve-letter-template/[templateId]/ReviewAndApproveLetterTemplatePage.module.scss create mode 100644 lambdas/backend-api/src/__tests__/api/approve-template.test.ts create mode 100644 tests/test-team/pages/letter/template-mgmt-review-and-approve-letter-template-page.ts create mode 100644 tests/test-team/template-mgmt-api-tests/approve-template.api.spec.ts create mode 100644 tests/test-team/template-mgmt-component-tests/letter/template-mgmt-review-and-approve-letter-template-page.component.spec.ts diff --git a/frontend/src/__tests__/app/review-and-approve-letter-template/page.test.tsx b/frontend/src/__tests__/app/review-and-approve-letter-template/page.test.tsx new file mode 100644 index 000000000..23457bcd6 --- /dev/null +++ b/frontend/src/__tests__/app/review-and-approve-letter-template/page.test.tsx @@ -0,0 +1,210 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { getTemplate } from '@utils/form-actions'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { + AUTHORING_LETTER_TEMPLATE, + EMAIL_TEMPLATE, + NHS_APP_TEMPLATE, + PDF_LETTER_TEMPLATE, + SMS_TEMPLATE, +} from '@testhelpers/helpers'; +import { AuthoringLetterTemplate } from 'nhs-notify-web-template-management-utils'; +import Page, { + generateMetadata, +} from '@app/review-and-approve-letter-template/[templateId]/page'; +import { reviewAndApproveLetterTemplateAction } from '@app/review-and-approve-letter-template/[templateId]/server-action'; +import content from '@content/content'; + +jest.mock('@utils/form-actions'); +jest.mock('next/navigation'); +jest.mock('@app/review-and-approve-letter-template/[templateId]/server-action'); +jest.mock('@utils/csrf-utils'); + +const { pageTitle } = content.pages.reviewAndApproveLetterTemplate; + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(reviewAndApproveLetterTemplateAction).mockResolvedValue({}); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); +}); + +test('metadata', async () => { + expect(await generateMetadata()).toEqual({ + title: pageTitle, + }); +}); + +describe('template does not exist', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue(undefined); + }); + + it('redirects to invalid template page', async () => { + await Page({ + params: Promise.resolve({ templateId: 'template-123' }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); +}); + +describe('template is not a letter', () => { + it('redirects to invalid template page when template is an email', async () => { + jest.mocked(getTemplate).mockResolvedValue(EMAIL_TEMPLATE); + + await Page({ + params: Promise.resolve({ templateId: EMAIL_TEMPLATE.id }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); + + it('redirects to invalid template page when template is an SMS', async () => { + jest.mocked(getTemplate).mockResolvedValue(SMS_TEMPLATE); + + await Page({ + params: Promise.resolve({ templateId: SMS_TEMPLATE.id }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); + + it('redirects to invalid template page when template is an NHS App message', async () => { + jest.mocked(getTemplate).mockResolvedValue(NHS_APP_TEMPLATE); + + await Page({ + params: Promise.resolve({ templateId: NHS_APP_TEMPLATE.id }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); +}); + +describe('template is a PDF letter (not AUTHORING)', () => { + it('redirects to invalid template page when letterVersion is PDF', async () => { + jest.mocked(getTemplate).mockResolvedValue(PDF_LETTER_TEMPLATE); + + await Page({ + params: Promise.resolve({ templateId: PDF_LETTER_TEMPLATE.id }), + }); + + expect(redirect).toHaveBeenCalledWith( + '/invalid-template', + RedirectType.replace + ); + }); +}); + +describe('valid authoring letter template', () => { + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue(AUTHORING_LETTER_TEMPLATE); + }); + + it('renders the page without redirecting', async () => { + const page = await Page({ + params: Promise.resolve({ templateId: AUTHORING_LETTER_TEMPLATE.id }), + }); + + expect(page).toBeTruthy(); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('renders hidden templateId and lockNumber inputs', async () => { + render( + await Page({ + params: Promise.resolve({ templateId: AUTHORING_LETTER_TEMPLATE.id }), + }) + ); + + const form = screen.getByTestId('preview-letter-template-cta'); + expect(form).toBeInTheDocument(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render( + await Page({ + params: Promise.resolve({ templateId: AUTHORING_LETTER_TEMPLATE.id }), + }) + ); + + await user.click(screen.getByTestId('preview-letter-template-cta')); + + expect(reviewAndApproveLetterTemplateAction).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(reviewAndApproveLetterTemplateAction).mock + .calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('templateId')).toBe(AUTHORING_LETTER_TEMPLATE.id); + expect(formData.get('lockNumber')).toBe( + String(AUTHORING_LETTER_TEMPLATE.lockNumber) + ); + }); +}); + +describe('rendered PDF previews', () => { + const templateWithRenderedFiles: AuthoringLetterTemplate = { + ...AUTHORING_LETTER_TEMPLATE, + clientId: 'client-123', + files: { + ...AUTHORING_LETTER_TEMPLATE.files, + shortFormRender: { + fileName: 'short-form.pdf', + currentVersion: 'v1', + status: 'RENDERED', + pageCount: 1, + }, + longFormRender: { + fileName: 'long-form.pdf', + currentVersion: 'v2', + status: 'RENDERED', + pageCount: 3, + }, + }, + }; + + beforeEach(() => { + jest.mocked(getTemplate).mockResolvedValue(templateWithRenderedFiles); + }); + + it('renders iframes with PDF URLs when renders are available', async () => { + render( + await Page({ + params: Promise.resolve({ + templateId: templateWithRenderedFiles.id, + }), + }) + ); + + const iframes = document.querySelectorAll('iframe'); + expect(iframes).toHaveLength(2); + + const shortIframe = iframes[0]; + const longIframe = iframes[1]; + + expect(shortIframe).toHaveAttribute( + 'src', + `/templates/files/client-123/renders/${templateWithRenderedFiles.id}/short-form.pdf` + ); + expect(longIframe).toHaveAttribute( + 'src', + `/templates/files/client-123/renders/${templateWithRenderedFiles.id}/long-form.pdf` + ); + }); +}); diff --git a/frontend/src/__tests__/app/review-and-approve-letter-template/server-action.test.ts b/frontend/src/__tests__/app/review-and-approve-letter-template/server-action.test.ts new file mode 100644 index 000000000..6cab682a6 --- /dev/null +++ b/frontend/src/__tests__/app/review-and-approve-letter-template/server-action.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ +import { reviewAndApproveLetterTemplateAction } from '@app/review-and-approve-letter-template/[templateId]/server-action'; +import { redirect } from 'next/navigation'; +import { approveTemplate } from '@utils/form-actions'; + +jest.mock('next/navigation'); +jest.mock('@utils/form-actions'); + +const redirectMock = jest.mocked(redirect); +const approveTemplateMock = jest.mocked(approveTemplate); + +describe('reviewAndApproveLetterTemplateAction', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should call approveTemplate and redirect on valid form data', async () => { + approveTemplateMock.mockResolvedValueOnce({ + id: 'template-123', + name: 'name', + templateStatus: 'PROOF_APPROVED', + templateType: 'LETTER', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + lockNumber: 2, + language: 'en', + letterType: 'x0', + letterVersion: 'AUTHORING', + files: { + docxTemplate: { + fileName: 'template.docx', + currentVersion: 'v1', + virusScanStatus: 'PASSED', + }, + initialRender: { + status: 'RENDERED', + fileName: 'render.pdf', + currentVersion: 'v1', + pageCount: 2, + }, + }, + }); + + const formData = new FormData(); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', '1'); + + await reviewAndApproveLetterTemplateAction({}, formData); + + expect(approveTemplateMock).toHaveBeenCalledWith('template-123', 1); + expect(redirectMock).toHaveBeenCalledWith( + '/letter-template-approved/template-123' + ); + }); + + it('should return error state when templateId is missing', async () => { + const formData = new FormData(); + formData.append('lockNumber', '1'); + + const result = await reviewAndApproveLetterTemplateAction({}, formData); + + expect(result).toHaveProperty('errorState'); + expect(approveTemplateMock).not.toHaveBeenCalled(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + + it('should return error state when lockNumber is missing', async () => { + const formData = new FormData(); + formData.append('templateId', 'template-123'); + + const result = await reviewAndApproveLetterTemplateAction({}, formData); + + expect(result).toHaveProperty('errorState'); + expect(approveTemplateMock).not.toHaveBeenCalled(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + + it('should return error state when lockNumber is invalid', async () => { + const formData = new FormData(); + formData.append('templateId', 'template-123'); + formData.append('lockNumber', 'not-a-number'); + + const result = await reviewAndApproveLetterTemplateAction({}, formData); + + expect(result).toHaveProperty('errorState'); + expect(approveTemplateMock).not.toHaveBeenCalled(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + + it('should return error state when templateId is empty', async () => { + const formData = new FormData(); + formData.append('templateId', ''); + formData.append('lockNumber', '1'); + + const result = await reviewAndApproveLetterTemplateAction({}, formData); + + expect(result).toHaveProperty('errorState'); + expect(approveTemplateMock).not.toHaveBeenCalled(); + expect(redirectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/utils/form-actions.test.ts b/frontend/src/__tests__/utils/form-actions.test.ts index a19be1c02..80af25627 100644 --- a/frontend/src/__tests__/utils/form-actions.test.ts +++ b/frontend/src/__tests__/utils/form-actions.test.ts @@ -19,6 +19,7 @@ import { setTemplateToSubmitted, requestTemplateProof, uploadDocxTemplate, + approveTemplate, } from '@utils/form-actions'; import { getSessionServer } from '@utils/amplify-utils'; import { @@ -885,6 +886,82 @@ describe('form-actions', () => { }); }); + describe('approveTemplate', () => { + test('approveTemplate successfully', async () => { + const responseData = { + id: 'id', + templateType: 'LETTER', + templateStatus: 'PROOF_APPROVED', + name: 'name', + createdAt: '2025-01-13T10:19:25.579Z', + updatedAt: '2025-01-13T10:19:25.579Z', + lockNumber: 1, + language: 'en', + letterType: 'x0', + letterVersion: 'AUTHORING', + files: { + docxTemplate: { + fileName: 'template.docx', + currentVersion: 'v1', + virusScanStatus: 'PASSED', + }, + initialRender: { + status: 'RENDERED', + fileName: 'render.pdf', + currentVersion: 'v1', + pageCount: 2, + }, + }, + } satisfies TemplateDto; + + mockedTemplateClient.approveTemplate.mockResolvedValueOnce({ + data: responseData, + }); + + const response = await approveTemplate('id', 0); + + expect(mockedTemplateClient.approveTemplate).toHaveBeenCalledWith( + 'id', + 'token', + 0 + ); + + expect(response).toEqual(responseData); + }); + + test('approveTemplate - should throw error when saving unexpectedly fails', async () => { + mockedTemplateClient.approveTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Bad request', + }, + }, + }); + + await expect(approveTemplate('id', 0)).rejects.toThrow( + 'Failed to approve template' + ); + + expect(mockedTemplateClient.approveTemplate).toHaveBeenCalledWith( + 'id', + 'token', + 0 + ); + }); + + test('approveTemplate - should throw error when no token', async () => { + authIdTokenServerMock.mockResolvedValueOnce({ + accessToken: undefined, + clientId: undefined, + }); + + await expect(approveTemplate('id', 0)).rejects.toThrow( + 'Failed to get access token' + ); + }); + }); + describe('setTemplateToDeleted', () => { test('deleteTemplate successfully', async () => { mockedTemplateClient.deleteTemplate.mockResolvedValueOnce({ diff --git a/frontend/src/app/review-and-approve-letter-template/[templateId]/ReviewAndApproveLetterTemplatePage.module.scss b/frontend/src/app/review-and-approve-letter-template/[templateId]/ReviewAndApproveLetterTemplatePage.module.scss new file mode 100644 index 000000000..40769cbf3 --- /dev/null +++ b/frontend/src/app/review-and-approve-letter-template/[templateId]/ReviewAndApproveLetterTemplatePage.module.scss @@ -0,0 +1,4 @@ +.iframe { + width: 100%; + height: 1200px +} diff --git a/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx b/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx index cd4c15d92..3986fec2c 100644 --- a/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx +++ b/frontend/src/app/review-and-approve-letter-template/[templateId]/page.tsx @@ -3,22 +3,32 @@ import { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { + AuthoringLetterTemplate, TemplatePageProps, validateLetterTemplate, } from 'nhs-notify-web-template-management-utils'; import { getTemplate } from '@utils/form-actions'; import content from '@content/content'; -import { $LockNumber } from 'nhs-notify-backend-client'; import { NHSNotifyContainer } from '@layouts/container/container'; import { NHSNotifyFormProvider } from '@providers/form-provider'; import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; -import Link from 'next/link'; import { reviewAndApproveLetterTemplateAction } from './server-action'; import PreviewTemplateDetailsAuthoringLetter from '@molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter'; import { LetterRenderIframe } from '@molecules/LetterRender/LetterRenderIframe'; +import { getBasePath } from '@utils/get-base-path'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import styles from './ReviewAndApproveLetterTemplatePage.module.scss'; +import { interpolate } from '@utils/interpolate'; -const { pageTitle } = content.pages.reviewAndApproveLetterTemplate; +const { + pageTitle, + goBackButtonText, + goBackPath, + shortExampleHeading, + longExampleHeading, + submitText, +} = content.pages.reviewAndApproveLetterTemplate; export async function generateMetadata(): Promise { return { @@ -26,19 +36,24 @@ export async function generateMetadata(): Promise { }; } -const ReviewAndApproveLetterTemplatePage = async (props: TemplatePageProps) => { - const { templateId } = await props.params; +function buildPdfUrl(template: AuthoringLetterTemplate, fileName: string) { + const basePath = getBasePath(); + return `${basePath}/files/${template.clientId}/renders/${template.id}/${fileName}`; +} - const searchParams = await props.searchParams; +function derivePdfUrl( + template: AuthoringLetterTemplate, + tab: 'longFormRender' | 'shortFormRender' +): string | null { + const render = template.files[tab]; - const lockNumberResult = $LockNumber.safeParse(searchParams?.lockNumber); + return render?.status === 'RENDERED' + ? buildPdfUrl(template, render.fileName) + : null; +} - if (!lockNumberResult.success) { - return redirect( - `/preview-letter-template/${templateId}`, - RedirectType.replace - ); - } +const ReviewAndApproveLetterTemplatePage = async (props: TemplatePageProps) => { + const { templateId } = await props.params; const template = await getTemplate(templateId); @@ -54,9 +69,26 @@ const ReviewAndApproveLetterTemplatePage = async (props: TemplatePageProps) => { - - - + {/* + add step 2 of 3 to details header somehow + */} + +

{shortExampleHeading}

+ +

{longExampleHeading}

+ { name='lockNumber' value={validatedTemplate.lockNumber} /> - - +
+ + {submitText} + -

- {'backLinkText'} -

+ + {goBackButtonText} + +
+
diff --git a/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts b/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts index 6db9610c9..e3e34493d 100644 --- a/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts +++ b/frontend/src/app/review-and-approve-letter-template/[templateId]/server-action.ts @@ -4,6 +4,7 @@ import { z } from 'zod/v4'; import { $LockNumber } from 'nhs-notify-backend-client'; import type { FormState } from 'nhs-notify-web-template-management-utils'; import { redirect } from 'next/navigation'; +import { approveTemplate } from '@utils/form-actions'; const $FormSchema = z.object({ templateId: z.string().nonempty(), @@ -24,5 +25,7 @@ export async function reviewAndApproveLetterTemplateAction( const { templateId, lockNumber } = result.data; - redirect(`/submit-letter-template/${templateId}?lockNumber=${lockNumber}`); + await approveTemplate(templateId, lockNumber); + + redirect(`/letter-template-approved/${templateId}`); } diff --git a/frontend/src/components/molecules/LetterRender/LetterRenderIframe.tsx b/frontend/src/components/molecules/LetterRender/LetterRenderIframe.tsx index 960e44ebb..3a114745e 100644 --- a/frontend/src/components/molecules/LetterRender/LetterRenderIframe.tsx +++ b/frontend/src/components/molecules/LetterRender/LetterRenderIframe.tsx @@ -3,9 +3,13 @@ import type { PersonalisedRenderKey } from '@utils/types'; type LetterRenderIframeProps = { tab: PersonalisedRenderKey; pdfUrl: string | null; -}; +} & React.ComponentProps<'iframe'>; -export function LetterRenderIframe({ tab, pdfUrl }: LetterRenderIframeProps) { +export function LetterRenderIframe({ + tab, + pdfUrl, + ...rest +}: LetterRenderIframeProps) { if (!pdfUrl) return

No preview available

; const tabDescription = tab === 'shortFormRender' ? 'short' : 'long'; @@ -14,6 +18,7 @@ export function LetterRenderIframe({ tab, pdfUrl }: LetterRenderIframeProps) { src={pdfUrl} title={`Letter preview - ${tabDescription} examples`} aria-label={`PDF preview of letter template with ${tabDescription} example personalisation data`} + {...rest} /> ); } diff --git a/frontend/src/components/molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter.tsx b/frontend/src/components/molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter.tsx index f1b1991a5..281ca2fe4 100644 --- a/frontend/src/components/molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter.tsx +++ b/frontend/src/components/molecules/PreviewTemplateDetails/PreviewTemplateDetailsAuthoringLetter.tsx @@ -38,10 +38,12 @@ export default function PreviewTemplateDetailsAuthoringLetter({ template, hideStatus, hideEditActions, + hideLearnMore, }: { template: AuthoringLetterTemplate; hideStatus?: boolean; hideEditActions?: boolean; + hideLearnMore?: boolean; }) { const features = useFeatureFlags(); const campaignIds = useCampaignIds(); @@ -161,6 +163,7 @@ export default function PreviewTemplateDetailsAuthoringLetter({ label={actions.learnMore} visuallyHiddenText={visuallyHidden.sheets} testId='sheets-action' + hidden={hideLearnMore} external /> @@ -205,6 +208,7 @@ export default function PreviewTemplateDetailsAuthoringLetter({ label={actions.learnMore} visuallyHiddenText={visuallyHidden.status} testId='status-action' + hidden={hideLearnMore} external /> diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index f2ed954ed..f7dbdd986 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1793,6 +1793,12 @@ const reviewAndMoveToProduction = { const reviewAndApproveLetterTemplate = { pageTitle: generatePageTitle('Review and approve letter template'), + goBackButtonText: 'Go back', + goBackPath: + '{{basePath}}/get-ready-to-approve-letter-template/{{templateId}}', + shortExampleHeading: 'Short example preview', + longExampleHeading: 'Long example preview', + submitText: 'Approve letter template', }; const editTemplateNamePage = { diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index 147744e37..ec7962901 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -110,6 +110,30 @@ export async function saveTemplate( return data; } +export async function approveTemplate( + templateId: string, + lockNumber: number +): Promise { + const { accessToken } = await getSessionServer(); + + if (!accessToken) { + throw new Error('Failed to get access token'); + } + + const { data, error } = await templateApiClient.approveTemplate( + templateId, + accessToken, + lockNumber + ); + + if (error) { + logger.error('Failed to approve template', error); + throw new Error('Failed to approve template'); + } + + return data; +} + export async function patchTemplate( templateId: string, template: PatchTemplate, diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 4ea274cc0..a41347412 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -39,7 +39,7 @@ No requirements. | Name | Source | Version | |------|--------|---------| -| [approve\_template\_lambda](#module\_approve\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v3.0.6/terraform-lambda.zip | n/a | +| [approve\_template\_lambda](#module\_approve\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/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.29/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.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf b/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf index 03325cb27..3fde1d75b 100644 --- a/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_approve_template_lambda.tf @@ -1,5 +1,5 @@ module "approve_template_lambda" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v3.0.6/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip" project = var.project environment = var.environment diff --git a/lambdas/backend-api/src/__tests__/api/approve-template.test.ts b/lambdas/backend-api/src/__tests__/api/approve-template.test.ts new file mode 100644 index 000000000..314165a1a --- /dev/null +++ b/lambdas/backend-api/src/__tests__/api/approve-template.test.ts @@ -0,0 +1,258 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import type { TemplateDto } from 'nhs-notify-web-template-management-types'; +import { createHandler } from '../../api/approve-template'; +import { TemplateClient } from '../../app/template-client'; + +const setup = () => { + const templateClient = mock(); + + const handler = createHandler({ templateClient }); + + return { handler, mocks: { templateClient } }; +}; + +describe('Template API - Approve', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', internalUserId: undefined }], + ['missing client', { clientId: undefined, internalUserId: 'user-1234' }], + ])( + 'should return 400 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + pathParameters: { templateId: 'id' }, + headers: { 'X-Lock-Number': '0' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.templateClient.approveTemplate).not.toHaveBeenCalled(); + } + ); + + test('should return 400 - Invalid request when, no templateId', async () => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: undefined }, + headers: { 'X-Lock-Number': '0' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.templateClient.approveTemplate).not.toHaveBeenCalled(); + }); + + test('should return error when approving template fails', async () => { + const { handler, mocks } = setup(); + + mocks.templateClient.approveTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: '1-2-3' }, + headers: { 'X-Lock-Number': '0' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.templateClient.approveTemplate).toHaveBeenCalledWith( + '1-2-3', + { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + '0' + ); + }); + + test('should return error with details when approving template fails with details', async () => { + const { handler, mocks } = setup(); + + mocks.templateClient.approveTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Template cannot be approved', + details: { reason: 'not a letter' }, + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: '1-2-3' }, + headers: { 'X-Lock-Number': '1' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Template cannot be approved', + details: { reason: 'not a letter' }, + }), + }); + }); + + test('should coerce missing lock number header to empty string', async () => { + const { handler, mocks } = setup(); + + mocks.templateClient.approveTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 409, + description: + 'Lock number mismatch - Template has been modified since last read', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: '1-2-3' }, + headers: {}, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 409, + body: JSON.stringify({ + statusCode: 409, + technicalMessage: + 'Lock number mismatch - Template has been modified since last read', + }), + }); + + expect(mocks.templateClient.approveTemplate).toHaveBeenCalledWith( + '1-2-3', + { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + '' + ); + }); + + test('should return template on success', async () => { + const { handler, mocks } = setup(); + + const response: TemplateDto = { + id: '1-2-3', + name: 'approved-template', + templateStatus: 'PROOF_APPROVED', + templateType: 'LETTER', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lockNumber: 1, + language: 'en', + letterType: 'x0', + letterVersion: 'AUTHORING', + files: { + docxTemplate: { + fileName: 'template.docx', + currentVersion: 'v1', + virusScanStatus: 'PASSED', + }, + initialRender: { + status: 'RENDERED', + fileName: 'render.pdf', + currentVersion: 'v1', + pageCount: 2, + }, + }, + }; + + mocks.templateClient.approveTemplate.mockResolvedValueOnce({ + data: response, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { templateId: '1-2-3' }, + headers: { 'X-Lock-Number': '0' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: response }), + }); + + expect(mocks.templateClient.approveTemplate).toHaveBeenCalledWith( + '1-2-3', + { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + '0' + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/app/template-client.test.ts b/lambdas/backend-api/src/__tests__/app/template-client.test.ts index 700f7deb8..1ad8813ee 100644 --- a/lambdas/backend-api/src/__tests__/app/template-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/template-client.test.ts @@ -3202,6 +3202,173 @@ describe('templateClient', () => { }); }); + describe('approveTemplate', () => { + const notYetSubmittedLetterDto: TemplateDto = { + id: templateId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + templateStatus: 'NOT_YET_SUBMITTED', + name: 'name', + templateType: 'LETTER', + lockNumber: 1, + language: 'en', + letterType: 'x0', + letterVersion: 'AUTHORING', + files: { + docxTemplate: { + fileName: 'template.docx', + currentVersion: 'v1', + virusScanStatus: 'PASSED', + }, + initialRender: { + status: 'RENDERED', + fileName: 'render.pdf', + currentVersion: 'v1', + pageCount: 2, + }, + }, + }; + + test('returns failure result when lock number is invalid', async () => { + const { templateClient, mocks } = setup(); + + const result = await templateClient.approveTemplate(templateId, user, ''); + + expect( + mocks.templateRepository.approveLetterTemplate + ).not.toHaveBeenCalled(); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 400, + description: 'Invalid lock number provided', + }, + }, + }); + }); + + test('should return a failure result when saving to the database fails', async () => { + const { templateClient, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { + ...notYetSubmittedLetterDto, + owner: `CLIENT#${user.clientId}`, + version: 1, + }, + }); + + mocks.templateRepository.approveLetterTemplate.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const result = await templateClient.approveTemplate( + templateId, + user, + '0' + ); + + expect( + mocks.templateRepository.approveLetterTemplate + ).toHaveBeenCalledWith(templateId, user, 0); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + }); + + test('should return a failure result when updated database template is invalid', async () => { + const { templateClient, mocks } = setup(); + + const template: DatabaseTemplate = { + ...notYetSubmittedLetterDto, + createdAt: undefined as unknown as string, + owner: `CLIENT#${user.clientId}`, + version: 1, + }; + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { + ...notYetSubmittedLetterDto, + owner: `CLIENT#${user.clientId}`, + version: 1, + }, + }); + + mocks.templateRepository.approveLetterTemplate.mockResolvedValueOnce({ + data: template, + }); + + const result = await templateClient.approveTemplate( + templateId, + user, + '0' + ); + + expect( + mocks.templateRepository.approveLetterTemplate + ).toHaveBeenCalledWith(templateId, user, 0); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 500, + description: 'Error retrieving template', + }, + }, + }); + }); + + test('should return template approved to PROOF_APPROVED', async () => { + const { templateClient, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { + ...notYetSubmittedLetterDto, + owner: `CLIENT#${user.clientId}`, + version: 1, + }, + }); + + mocks.templateRepository.approveLetterTemplate.mockResolvedValueOnce({ + data: { + ...notYetSubmittedLetterDto, + templateStatus: 'PROOF_APPROVED', + owner: `CLIENT#${user.clientId}`, + version: 1, + }, + }); + + const result = await templateClient.approveTemplate( + templateId, + user, + '0' + ); + + expect( + mocks.templateRepository.approveLetterTemplate + ).toHaveBeenCalledWith(templateId, user, 0); + + expect(result).toEqual({ + data: { + ...notYetSubmittedLetterDto, + templateStatus: 'PROOF_APPROVED', + }, + }); + }); + }); + describe('requestProof', () => { test('returns failure result when lock number is invalid', async () => { const { templateClient, mocks } = setup(); diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts index e92ab9cbe..31403db53 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts @@ -968,6 +968,148 @@ describe('templateRepository', () => { }); }); + describe('approveLetterTemplate', () => { + test.each([ + { + testName: 'When template does not exist', + Item: undefined, + code: 404, + message: 'Template not found', + returnActualError: false, + }, + { + testName: 'Fails when template status is DELETED', + Item: marshall({ + templateType: 'LETTER', + templateStatus: 'DELETED', + lockNumber: 0, + }), + code: 404, + message: 'Template not found', + returnActualError: false, + }, + { + testName: 'Fails when stored lock number differs from input', + Item: marshall({ + templateType: 'LETTER', + templateStatus: 'NOT_YET_SUBMITTED', + lockNumber: 1, + }), + code: 409, + message: + 'Lock number mismatch - Template has been modified since last read', + returnActualError: true, + }, + { + testName: + 'Fails when template cannot be approved (condition check fails for other reasons)', + Item: marshall({ + templateType: 'LETTER', + templateStatus: 'SUBMITTED', + lockNumber: 0, + }), + code: 400, + message: 'Template cannot be approved', + returnActualError: true, + }, + ])( + 'approveLetterTemplate: $testName', + async ({ Item, code, message, returnActualError }) => { + const { templateRepository, mocks } = setup(); + + const error = new ConditionalCheckFailedException({ + message: 'mocked', + $metadata: { httpStatusCode: 400 }, + Item, + }); + + mocks.ddbDocClient.on(UpdateCommand).rejects(error); + + const response = await templateRepository.approveLetterTemplate( + 'abc-def-ghi-jkl-123', + user, + 0 + ); + + expect(response).toEqual({ + error: { + ...(returnActualError ? { actualError: error } : {}), + errorMeta: { + code, + description: message, + }, + }, + }); + } + ); + + test('should return error when an unexpected error occurs', async () => { + const { templateRepository, mocks } = setup(); + + const error = new Error('mocked'); + + mocks.ddbDocClient.on(UpdateCommand).rejects(error); + + const response = await templateRepository.approveLetterTemplate( + 'abc-def-ghi-jkl-123', + user, + 0 + ); + + expect(response).toEqual({ + error: { + actualError: error, + errorMeta: { + code: 500, + description: 'Failed to update template', + }, + }, + }); + }); + + test('should update template status to PROOF_APPROVED', async () => { + const { templateRepository, mocks } = setup(); + const id = 'abc-def-ghi-jkl-123'; + + const databaseTemplate: DatabaseTemplate = { + id, + owner: ownerWithClientPrefix, + version: 1, + name: 'updated-name', + message: 'updated-message', + templateStatus: 'PROOF_APPROVED', + templateType: 'LETTER', + updatedAt: 'now', + createdAt: 'yesterday', + lockNumber: 1, + }; + + mocks.ddbDocClient + .on(UpdateCommand, { + TableName: templatesTableName, + Key: { id, owner: ownerWithClientPrefix }, + }) + .resolves({ Attributes: databaseTemplate }); + + const response = await templateRepository.approveLetterTemplate( + id, + user, + 0 + ); + + expect(response).toEqual({ + data: databaseTemplate, + }); + + expect(mocks.ddbDocClient).toHaveReceivedCommandWith(UpdateCommand, { + TableName: 'templates', + Key: { id: 'abc-def-ghi-jkl-123', owner: 'CLIENT#client-id' }, + ReturnValues: 'ALL_NEW', + ReturnValuesOnConditionCheckFailure: 'ALL_OLD', + }); + }); + }); + describe('approveProof', () => { test('should update template status to PROOF_APPROVED', async () => { const { templateRepository, mocks } = setup(); diff --git a/lambdas/backend-api/src/app/template-client.ts b/lambdas/backend-api/src/app/template-client.ts index 6fd585192..a58cb2278 100644 --- a/lambdas/backend-api/src/app/template-client.ts +++ b/lambdas/backend-api/src/app/template-client.ts @@ -579,27 +579,6 @@ export class TemplateClient { ); } - const { data: template, error: templateError } = await this.getTemplate( - templateId, - user - ); - - if (templateError) { - log - .child(templateError.errorMeta) - .error('Failed to get template', templateError.actualError); - - return { error: templateError }; - } - - if (template.templateType !== 'LETTER') { - log - .child({ templateType: template.templateType }) - .error('Only letters may be approved'); - - return failure(ErrorCase.VALIDATION_FAILED, 'Unexpected non-letter'); - } - const updateResult = await this.templateRepository.approveLetterTemplate( templateId, user, diff --git a/lambdas/backend-client/src/__tests__/template-api-client.test.ts b/lambdas/backend-client/src/__tests__/template-api-client.test.ts index 38c02b539..ef9533cd8 100644 --- a/lambdas/backend-client/src/__tests__/template-api-client.test.ts +++ b/lambdas/backend-client/src/__tests__/template-api-client.test.ts @@ -543,6 +543,58 @@ describe('TemplateAPIClient', () => { }); }); + describe('approveTemplate', () => { + test('should return error', async () => { + axiosMock.onPatch('/v1/template/real-id/approve').reply(400, { + statusCode: 400, + technicalMessage: 'Bad request', + details: { + message: 'Template cannot be approved', + }, + }); + + const result = await client.approveTemplate('real-id', testToken, 2); + + expect(result.error).toEqual({ + errorMeta: { + code: 400, + description: 'Bad request', + details: { + message: 'Template cannot be approved', + }, + }, + }); + + expect(result.data).toBeUndefined(); + + expect(axiosMock.history.patch.length).toBe(1); + + const headers = axiosMock.history.at(0)?.headers; + + expect(headers ? headers['X-Lock-Number'] : null).toEqual('2'); + }); + + test('should return template', async () => { + const data = { + id: 'real-id', + name: 'name', + templateStatus: 'PROOF_APPROVED', + templateType: 'LETTER', + }; + + axiosMock.onPatch('/v1/template/real-id/approve').reply(200, { + statusCode: 200, + data, + }); + + const result = await client.approveTemplate('real-id', testToken, 2); + + expect(result.data).toEqual(data); + + expect(result.error).toBeUndefined(); + }); + }); + describe('deleteTemplate', () => { test('should return error', async () => { axiosMock.onDelete('/v1/template/real-id').reply(400, { diff --git a/tests/test-team/pages/letter/index.ts b/tests/test-team/pages/letter/index.ts index ce9976760..e5e848e17 100644 --- a/tests/test-team/pages/letter/index.ts +++ b/tests/test-team/pages/letter/index.ts @@ -2,6 +2,7 @@ export * from './template-mgmt-edit-template-campaign-page'; export * from './template-mgmt-edit-template-name-page'; export * from './template-mgmt-preview-letter-page'; export * from './template-mgmt-preview-submitted-letter-page'; +export * from './template-mgmt-review-and-approve-letter-template-page'; export * from './template-mgmt-submit-letter-page'; export * from './template-mgmt-template-submitted-letter-page'; export * from './template-mgmt-upload-bsl-letter-template-page'; diff --git a/tests/test-team/pages/letter/template-mgmt-review-and-approve-letter-template-page.ts b/tests/test-team/pages/letter/template-mgmt-review-and-approve-letter-template-page.ts new file mode 100644 index 000000000..f26e8083b --- /dev/null +++ b/tests/test-team/pages/letter/template-mgmt-review-and-approve-letter-template-page.ts @@ -0,0 +1,25 @@ +import { Locator, Page } from '@playwright/test'; +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtReviewAndApproveLetterTemplatePage extends TemplateMgmtBasePage { + static readonly pathTemplate = + '/review-and-approve-letter-template/:templateId'; + + public static readonly urlRegexp = new RegExp( + /\/templates\/review-and-approve-letter-template\/([\dA-Fa-f-]+)$/ + ); + + readonly approveButton: Locator; + + constructor(page: Page) { + super(page); + + this.approveButton = page + .locator('[id="preview-letter-template-cta"]') + .and(page.getByRole('button')); + } + + async clickApproveButton() { + await this.approveButton.click(); + } +} diff --git a/tests/test-team/template-mgmt-api-tests/approve-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/approve-template.api.spec.ts new file mode 100644 index 000000000..a3b1e3953 --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/approve-template.api.spec.ts @@ -0,0 +1,374 @@ +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + type TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { + isoDateRegExp, + uuidRegExp, +} from 'nhs-notify-web-template-management-test-helper-utils'; +import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; +import { randomUUID } from 'node:crypto'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { Template } from 'helpers/types'; + +test.describe('PATCH /v1/template/:templateId/approve', () => { + const authHelper = createAuthHelper(); + const templateStorageHelper = new TemplateStorageHelper(); + let userRoutingDisabled: TestUser; + let userLetterAuthoring: TestUser; + let userLetterAuthoringSharedClient: TestUser; + + test.beforeAll(async () => { + userRoutingDisabled = await authHelper.getTestUser(testUsers.User2.userId); + userLetterAuthoring = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); + userLetterAuthoringSharedClient = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabledSharedClient.userId + ); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteAdHocTemplates(); + await templateStorageHelper.deleteSeededTemplates(); + }); + + const createAuthoringLetterTemplate = async ( + user: TestUser, + templateStatus = 'NOT_YET_SUBMITTED' + ): Promise