From 2348661c71214701dde6617e7dcc604a1de708dc Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 7 Oct 2025 12:40:05 +0100 Subject: [PATCH 01/12] Stash --- src/server/index.ts | 4 + ...maryPageWithConfirmationEmailController.ts | 105 ++++++++++++++++++ src/server/services/outputService.test.js | 22 ++-- src/server/services/outputService.ts | 7 +- src/server/types.ts | 5 +- 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 src/server/plugins/SummaryPageWithConfirmationEmailController.ts diff --git a/src/server/index.ts b/src/server/index.ts index 0071063c9..cd0a1fd58 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,6 +29,7 @@ import { requestLogger } from '~/src/server/common/helpers/logging/request-logge import { requestTracing } from '~/src/server/common/helpers/logging/request-tracing.js' import { buildRedisClient } from '~/src/server/common/helpers/redis-client.js' import { FORM_PREFIX } from '~/src/server/constants.js' +import { SummaryPageWithConfirmationEmailController } from '~/src/server/plugins/SummaryPageWithConfirmationEmailController.js' import { configureBlankiePlugin } from '~/src/server/plugins/blankie.js' import { configureCrumbPlugin } from '~/src/server/plugins/crumb.js' import pluginErrorPages from '~/src/server/plugins/errorPages.js' @@ -158,6 +159,9 @@ export const configureEnginePlugin = async ({ ? `/save-and-exit/${slug}` : `/save-and-exit/${slug}/${state}` ) + }, + controllers: { + SummaryPageWithConfirmationEmailController } } } diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts new file mode 100644 index 000000000..0d20f4291 --- /dev/null +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -0,0 +1,105 @@ +import { type PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' +import { type QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' +import { SummaryPageController } from '@defra/forms-engine-plugin/controllers/SummaryPageController.js' +import { type SummaryViewModel } from '@defra/forms-engine-plugin/engine/models/SummaryViewModel.js' +import { + type FormContext, + type FormContextRequest, + type FormPayload, + type FormSubmissionError +} from '@defra/forms-engine-plugin/engine/types.js' +import { + actionSchema, + crumbSchema, + userConfirmationEmailSchema +} from '@defra/forms-engine-plugin/schema.js' +import { + FormAction, + type FormRequestPayload, + type FormResponseToolkit +} from '@defra/forms-engine-plugin/types' +import { type GovukField } from '@defra/forms-model' +import Joi from 'joi' + +export const CONFIRMATION_EMAIL_FIELD_NAME = 'userConfirmationEmailAddress' + +const schema = Joi.object().keys({ + crumb: crumbSchema, + action: actionSchema, + userConfirmationEmailAddress: userConfirmationEmailSchema.messages({ + '*': 'Enter an email address in the correct format' + }) +}) + +export class SummaryPageWithConfirmationEmailController extends SummaryPageController { + getSummaryViewModel( + request: FormContextRequest, + context: FormContext + ): SummaryViewModel { + const viewModel = super.getSummaryViewModel(request, context) + viewModel.userConfirmationEmailField = getUserConfirmationEmailAddress( + request.payload, + context.errors + ) + return viewModel + } + + /** + * Returns an async function. This is called in plugin.ts when there is a POST request at `/{id}/{path*}`. + * If a form is incomplete, a user will be redirected to the start page. + */ + makePostRouteHandler() { + return async ( + request: FormRequestPayload, + context: FormContext, + h: FormResponseToolkit + ) => { + const viewName = (this as unknown as PageController).viewName + const { isForceAccess } = context + + // Check if this is a save-and-exit action + const { action } = request.payload + if (action === FormAction.SaveAndExit) { + return (this as unknown as QuestionPageController).handleSaveAndExit( + request, + context, + h + ) + } + + /** + * If there are any errors, render the page with the parsed errors + * @todo Refactor to match POST REDIRECT GET pattern + */ + const { error } = schema.validate(request.payload, { abortEarly: false }) + if (error || isForceAccess) { + context.errors = (this as unknown as QuestionPageController).getErrors( + error?.details + ) + const viewModel = this.getSummaryViewModel(request, context) + return h.view(viewName, viewModel) + } + + return this.handleFormSubmit(request, context, h) + } + } +} + +export function getUserConfirmationEmailAddress( + payload?: FormPayload, + errors?: FormSubmissionError[] +) { + return { + label: { + text: 'Confirmation email (optional)', + classes: 'govuk-label--m' + }, + id: CONFIRMATION_EMAIL_FIELD_NAME, + name: CONFIRMATION_EMAIL_FIELD_NAME, + hint: { + text: 'Enter your email address to get an email confirming your form has been submitted' + }, + value: payload ? payload[CONFIRMATION_EMAIL_FIELD_NAME] : undefined, + errorMessage: errors?.length ? errors[0].text : undefined + } as GovukField +} diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index d8caf9191..d55738a91 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -43,6 +43,8 @@ describe('OutputService', () => { /** @type {jest.Mock} */ let mockFormatter + const emailAddresses = { submissionEmailAddress: 'test@example.com' } + beforeEach(() => { jest.clearAllMocks() @@ -129,7 +131,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata @@ -165,7 +167,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse ) @@ -194,7 +196,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata @@ -214,7 +216,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata @@ -247,7 +249,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata @@ -278,7 +280,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse ) @@ -309,7 +311,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata @@ -341,7 +343,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'different@email.com', + { submissionEmailAddress: 'different@email.com' }, mockItems, mockSubmitResponse, mockFormMetadata @@ -442,7 +444,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - '', + { submissionEmailAddress: '' }, mockItems, mockSubmitResponse, mockFormMetadata @@ -471,7 +473,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'test@example.com', + emailAddresses, mockItems, mockSubmitResponse, mockFormMetadata diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index b46d81747..33632f4b9 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -29,7 +29,7 @@ export class OutputService implements IOutputService { * @param context - Form context from engine * @param request - Form request payload * @param model - Form model - * @param _emailAddress - Email address (ignored) + * @param emailAddresses: - { submissionEmailAddress: string, userConfirmationEmailAddress: string } - Email address (ignored) * @param items - Detail items from submission * @param submitResponse - Response from forms-submission-api * @param formMetadata - Form metadata (optional) @@ -38,7 +38,10 @@ export class OutputService implements IOutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - _emailAddress: string, + emailAddresses: { + submissionEmailAddress: string + userConfirmationEmailAddress?: string + }, items: DetailItem[], submitResponse: SubmitResponsePayload, formMetadata?: FormMetadata diff --git a/src/server/types.ts b/src/server/types.ts index 2569304f0..509e61a64 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -46,7 +46,10 @@ export interface OutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - emailAddress: string, + emailAddresses: { + submissionEmailAddress: string + userConfirmationEmailAddress?: string + }, items: DetailItem[], submitResponse: SubmitResponsePayload, formMetadata?: FormMetadata From c2f71cb881fa80682139bc4095abcd4bd0602697 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 7 Oct 2025 13:45:02 +0100 Subject: [PATCH 02/12] Stash --- ...ageWithConfirmationEmailController.test.ts | 143 ++++++++++++++++++ ...maryPageWithConfirmationEmailController.ts | 14 +- 2 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts new file mode 100644 index 000000000..e29df5650 --- /dev/null +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts @@ -0,0 +1,143 @@ +import { type CacheService } from '@defra/forms-engine-plugin/cache-service.js' +import { type QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' +import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' +import { buildFormRequest } from '@defra/forms-engine-plugin/engine/pageControllers/__stubs__/request.js' +import { type FormSubmissionState } from '@defra/forms-engine-plugin/engine/types.js' +import { + type FormRequest, + type FormRequestPayload, + type FormResponseToolkit +} from '@defra/forms-engine-plugin/types' +import { + ControllerType, + type PageSummaryWithConfirmationEmail +} from '@defra/forms-model' + +import { + SummaryPageWithConfirmationEmailController, + getUserConfirmationEmailAddress +} from '~/src/server/plugins/SummaryPageWithConfirmationEmailController.js' +import definition from '~/test/form/definitions/basic.js' + +describe('SummaryPageWithConfirmationEmailController', () => { + let model: FormModel + let controller: SummaryPageWithConfirmationEmailController + let requestPage: FormRequest + + const response = { + code: jest.fn().mockImplementation(() => response) + } + const h: FormResponseToolkit = { + redirect: jest.fn().mockReturnValue(response), + view: jest.fn() + } + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + + // Create a mock page for SummaryPageWithConfirmationEmailController + const mockPage = { + ...definition.pages[0], + controller: ControllerType.SummaryWithConfirmationEmail + } as unknown as PageSummaryWithConfirmationEmail + + controller = new SummaryPageWithConfirmationEmailController(model, mockPage) + + requestPage = buildFormRequest({ + method: 'get', + url: new URL('http://example.com/test/summary'), + path: '/test/summary', + params: { + path: 'summary', + slug: 'test' + }, + query: {}, + app: { model } + } as FormRequest) + }) + + describe('handle errors', () => { + it('should display errors including summary', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const request = { + ...requestPage, + method: 'post', + payload: { invalid: '123', action: 'send' } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + jest + .spyOn(controller as unknown as QuestionPageController, 'getState') + .mockResolvedValue({}) + jest + .spyOn(controller as unknown as QuestionPageController, 'setState') + .mockResolvedValue(state) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + const viewModel = controller.getSummaryViewModel(request, context) + + expect(h.view).toHaveBeenCalledWith('summary', expect.anything()) + expect(viewModel.errors).toHaveLength(1) + const errorText = viewModel.errors ? viewModel.errors[0].text : '' + expect(errorText).toBe('"invalid" is not allowed') + }) + }) + + describe('handleSaveAndExit', () => { + it('should invoke saveAndExit plugin option', async () => { + const saveAndExitMock = jest.fn(() => ({})) + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const request = { + ...requestPage, + server: { + plugins: { + 'forms-engine-plugin': { + saveAndExit: saveAndExitMock, + cacheService: { + clearState: jest.fn() + } as unknown as CacheService + } + } + }, + method: 'post', + payload: { fullName: 'John Smith', action: 'save-and-exit' } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + expect(saveAndExitMock).toHaveBeenCalledWith(request, h, context) + }) + }) + + describe('getUserConfirmationEmailAddress', () => { + test('should get confirmation email', () => { + const field = getUserConfirmationEmailAddress() + expect(field.name).toBe('userConfirmationEmailAddress') + expect(field.value).toBeUndefined() + }) + + test('should get confirmation email with retained value', () => { + const field = getUserConfirmationEmailAddress({ + userConfirmationEmailAddress: 'emailval' + }) + expect(field.name).toBe('userConfirmationEmailAddress') + expect(field.value).toBe('emailval') + }) + }) +}) diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index 0d20f4291..afe01717c 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -8,11 +8,7 @@ import { type FormPayload, type FormSubmissionError } from '@defra/forms-engine-plugin/engine/types.js' -import { - actionSchema, - crumbSchema, - userConfirmationEmailSchema -} from '@defra/forms-engine-plugin/schema.js' +import { actionSchema, crumbSchema } from '@defra/forms-engine-plugin/schema.js' import { FormAction, type FormRequestPayload, @@ -26,7 +22,7 @@ export const CONFIRMATION_EMAIL_FIELD_NAME = 'userConfirmationEmailAddress' const schema = Joi.object().keys({ crumb: crumbSchema, action: actionSchema, - userConfirmationEmailAddress: userConfirmationEmailSchema.messages({ + userConfirmationEmailAddress: Joi.string().email().messages({ '*': 'Enter an email address in the correct format' }) }) @@ -80,7 +76,11 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr return h.view(viewName, viewModel) } - return this.handleFormSubmit(request, context, h) + return (this as unknown as SummaryPageController).handleFormSubmit( + request, + context, + h + ) } } } From 57857ed8d10e83053d05a9771c5387386f86bced Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 7 Oct 2025 15:55:19 +0100 Subject: [PATCH 03/12] Adds confirmation email in message --- package-lock.json | 8 +-- package.json | 2 +- src/server/index.test.ts | 96 ---------------------------- src/server/services/outputService.ts | 13 +++- 4 files changed, 15 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index aaeb48d96..a280dc5d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@aws-sdk/client-sns": "^3.864.0", "@defra/forms-engine-plugin": "^3.0.7", - "@defra/forms-model": "^3.0.552", + "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2973,9 +2973,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.556", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.556.tgz", - "integrity": "sha512-ZmqUdms//UUDYDNS/TAqHUOro/kfP/T7x8vEIUS2IpD8zL+IP0pgu25sA7wbtYiucsWloiZBpxkjLSJEvT3mFA==", + "version": "3.0.559", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", + "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index 3280dffcc..2d7bfb227 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@aws-sdk/client-sns": "^3.864.0", "@defra/forms-engine-plugin": "^3.0.7", - "@defra/forms-model": "^3.0.552", + "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/index.test.ts b/src/server/index.test.ts index b38670206..491077a66 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,9 +1,3 @@ -import { getUploadStatus } from '@defra/forms-engine-plugin/engine/services/uploadService.js' -import { - FileStatus, - UploadStatus, - type UploadStatusResponse -} from '@defra/forms-engine-plugin/engine/types.js' import { FormStatus } from '@defra/forms-engine-plugin/types' import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' @@ -495,93 +489,3 @@ describe('Model cache', () => { }) }) }) - -describe('Upload status route', () => { - let server: Server - - beforeAll(async () => { - server = await createServer() - await server.initialize() - }) - - afterAll(async () => { - await server.stop() - }) - - beforeEach(() => { - jest.resetAllMocks() - }) - - test('GET /upload-status/{uploadId} returns upload status with 200 when successful', async () => { - const mockStatus: UploadStatusResponse = { - uploadStatus: UploadStatus.ready, - metadata: { - retrievalKey: 'some-key' - }, - form: { - file: { - fileId: 'some-file-id', - filename: 'some-file-name', - contentLength: 1024, - fileStatus: FileStatus.complete - } - }, - numberOfRejectedFiles: 0 - } - jest.mocked(getUploadStatus).mockResolvedValueOnce(mockStatus) - - const options = { - method: 'GET', - url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.OK) - expect(res.result).toEqual(mockStatus) - expect(getUploadStatus).toHaveBeenCalledWith( - '123e4567-e89b-12d3-a456-426614174000' - ) - }) - - test('GET /upload-status/{uploadId} returns 400 when status check fails', async () => { - jest.mocked(getUploadStatus).mockResolvedValueOnce(undefined) - - const options = { - method: 'GET', - url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.BAD_REQUEST) - expect(res.result).toEqual({ error: 'Status check failed' }) - }) - - test('GET /upload-status/{uploadId} returns 500 when exception occurs', async () => { - jest - .mocked(getUploadStatus) - .mockRejectedValueOnce(new Error('Service unavailable')) - - const options = { - method: 'GET', - url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR) - expect(res.result).toEqual({ error: 'Status check error' }) - }) - - test('GET /upload-status/{uploadId} returns 400 for invalid uploadId format', async () => { - const options = { - method: 'GET', - url: `${FORM_PREFIX}/upload-status/not-a-valid-guid` - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.BAD_REQUEST) - }) -}) diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 33632f4b9..457156043 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -29,7 +29,9 @@ export class OutputService implements IOutputService { * @param context - Form context from engine * @param request - Form request payload * @param model - Form model - * @param emailAddresses: - { submissionEmailAddress: string, userConfirmationEmailAddress: string } - Email address (ignored) + * @param options - availableoptions + * @param options.submissionEmailAddress - email address for submission to be sent to + * @param options.userConfirmationEmailAddress - email address for user confirmation to be sent to * @param items - Detail items from submission * @param submitResponse - Response from forms-submission-api * @param formMetadata - Form metadata (optional) @@ -38,8 +40,8 @@ export class OutputService implements IOutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - emailAddresses: { - submissionEmailAddress: string + options: { + submissionEmailAddress?: string userConfirmationEmailAddress?: string }, items: DetailItem[], @@ -80,6 +82,11 @@ export class OutputService implements IOutputService { return } + const confirmationEmail = options.userConfirmationEmailAddress + if (confirmationEmail) { + submissionPayload.meta.userConfirmationEmail = confirmationEmail + } + const messageId = await publishFormAdapterEvent(submissionPayload) logger.info( From 7f45a4ba2603d83c523afa260b71fafc325b9d5c Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 7 Oct 2025 15:59:21 +0100 Subject: [PATCH 04/12] Renamed param to 'options' --- src/server/services/outputService.test.js | 18 +++++++++--------- src/server/types.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index d55738a91..d97680507 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -43,7 +43,7 @@ describe('OutputService', () => { /** @type {jest.Mock} */ let mockFormatter - const emailAddresses = { submissionEmailAddress: 'test@example.com' } + const options = { submissionEmailAddress: 'test@example.com' } beforeEach(() => { jest.clearAllMocks() @@ -131,7 +131,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata @@ -167,7 +167,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse ) @@ -196,7 +196,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata @@ -216,7 +216,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata @@ -249,7 +249,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata @@ -280,7 +280,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse ) @@ -311,7 +311,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata @@ -473,7 +473,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - emailAddresses, + options, mockItems, mockSubmitResponse, mockFormMetadata diff --git a/src/server/types.ts b/src/server/types.ts index 509e61a64..76270fdb5 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -46,7 +46,7 @@ export interface OutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - emailAddresses: { + options: { submissionEmailAddress: string userConfirmationEmailAddress?: string }, From 32dce57d4ea3d9d89e8a62b33165ff8b00888bb1 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 7 Oct 2025 16:55:44 +0100 Subject: [PATCH 05/12] Schema correction --- .../plugins/SummaryPageWithConfirmationEmailController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index afe01717c..95ade30b7 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -22,7 +22,7 @@ export const CONFIRMATION_EMAIL_FIELD_NAME = 'userConfirmationEmailAddress' const schema = Joi.object().keys({ crumb: crumbSchema, action: actionSchema, - userConfirmationEmailAddress: Joi.string().email().messages({ + userConfirmationEmailAddress: Joi.string().email().allow('').messages({ '*': 'Enter an email address in the correct format' }) }) From 6eb7b8a25f692a30831c1ede449d5b4ca2312d00 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 8 Oct 2025 12:15:09 +0100 Subject: [PATCH 06/12] Plugin no longer supports userConfirmationEmail, move to Runner --- src/server/services/outputService.ts | 16 ++++------------ src/server/types.ts | 5 +---- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 457156043..0bf61bb26 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -29,9 +29,7 @@ export class OutputService implements IOutputService { * @param context - Form context from engine * @param request - Form request payload * @param model - Form model - * @param options - availableoptions - * @param options.submissionEmailAddress - email address for submission to be sent to - * @param options.userConfirmationEmailAddress - email address for user confirmation to be sent to + * @param emailAddress - email address for submission to be sent to * @param items - Detail items from submission * @param submitResponse - Response from forms-submission-api * @param formMetadata - Form metadata (optional) @@ -40,10 +38,7 @@ export class OutputService implements IOutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - options: { - submissionEmailAddress?: string - userConfirmationEmailAddress?: string - }, + emailAddress: string, items: DetailItem[], submitResponse: SubmitResponsePayload, formMetadata?: FormMetadata @@ -82,13 +77,10 @@ export class OutputService implements IOutputService { return } - const confirmationEmail = options.userConfirmationEmailAddress - if (confirmationEmail) { - submissionPayload.meta.userConfirmationEmail = confirmationEmail - } + submissionPayload.meta.userConfirmationEmail = + request.payload.userConfirmationEmailAddress const messageId = await publishFormAdapterEvent(submissionPayload) - logger.info( `Form submission notification published - ref: ${payloadRef}, formId: ${formId}, email: ${notificationEmail}, messageId: ${messageId}` ) diff --git a/src/server/types.ts b/src/server/types.ts index 76270fdb5..2569304f0 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -46,10 +46,7 @@ export interface OutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - options: { - submissionEmailAddress: string - userConfirmationEmailAddress?: string - }, + emailAddress: string, items: DetailItem[], submitResponse: SubmitResponsePayload, formMetadata?: FormMetadata From 65c74dfebda0c5398ca074d911e57da6fea80bd5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 8 Oct 2025 12:37:17 +0100 Subject: [PATCH 07/12] Extend Summary view to add our email input --- src/server/index.ts | 5 ++++- .../plugins/SummaryPageWithConfirmationEmailController.ts | 2 ++ .../custom-engine-views/summary-with-confirmation.html | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/server/views/custom-engine-views/summary-with-confirmation.html diff --git a/src/server/index.ts b/src/server/index.ts index cd0a1fd58..e6ec15b07 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -139,7 +139,10 @@ export const configureEnginePlugin = async ({ cache: 'session', nunjucks: { baseLayoutPath: 'layout.html', - paths + paths: [ + ...paths, + join(config.get('appDir'), 'views', 'custom-engine-views') + ] }, model, services, diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index 95ade30b7..a14c32e28 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -28,6 +28,8 @@ const schema = Joi.object().keys({ }) export class SummaryPageWithConfirmationEmailController extends SummaryPageController { + viewName = 'summary-with-confirmation' + getSummaryViewModel( request: FormContextRequest, context: FormContext diff --git a/src/server/views/custom-engine-views/summary-with-confirmation.html b/src/server/views/custom-engine-views/summary-with-confirmation.html new file mode 100644 index 000000000..0534a21ed --- /dev/null +++ b/src/server/views/custom-engine-views/summary-with-confirmation.html @@ -0,0 +1,7 @@ +{% extends 'summary.html' %} + +{% block customPageContent %} + {% if userConfirmationEmailField %} + {{ govukInput(userConfirmationEmailField) }} + {% endif %} +{% endblock %} From f5abec7ebc5ed1cdc71e8c531b7edb777bb8fe29 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 8 Oct 2025 14:14:31 +0100 Subject: [PATCH 08/12] Fixed tests --- ...ageWithConfirmationEmailController.test.ts | 5 ++++- src/server/services/outputService.test.js | 22 +++++++++---------- src/server/services/outputService.ts | 6 ++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts index e29df5650..b32a798a4 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts @@ -85,7 +85,10 @@ describe('SummaryPageWithConfirmationEmailController', () => { const viewModel = controller.getSummaryViewModel(request, context) - expect(h.view).toHaveBeenCalledWith('summary', expect.anything()) + expect(h.view).toHaveBeenCalledWith( + 'summary-with-confirmation', + expect.anything() + ) expect(viewModel.errors).toHaveLength(1) const errorText = viewModel.errors ? viewModel.errors[0].text : '' expect(errorText).toBe('"invalid" is not allowed') diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index d97680507..e63511ea5 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -43,8 +43,6 @@ describe('OutputService', () => { /** @type {jest.Mock} */ let mockFormatter - const options = { submissionEmailAddress: 'test@example.com' } - beforeEach(() => { jest.clearAllMocks() @@ -131,7 +129,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -167,7 +165,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse ) @@ -196,7 +194,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -216,7 +214,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -249,7 +247,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -280,7 +278,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse ) @@ -311,7 +309,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -343,7 +341,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - { submissionEmailAddress: 'different@email.com' }, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -444,7 +442,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - { submissionEmailAddress: '' }, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -473,7 +471,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - options, + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 0bf61bb26..897310231 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -29,7 +29,7 @@ export class OutputService implements IOutputService { * @param context - Form context from engine * @param request - Form request payload * @param model - Form model - * @param emailAddress - email address for submission to be sent to + * @param _emailAddress - email address for submission to be sent to (not used) * @param items - Detail items from submission * @param submitResponse - Response from forms-submission-api * @param formMetadata - Form metadata (optional) @@ -38,7 +38,7 @@ export class OutputService implements IOutputService { context: FormContext, request: FormRequestPayload, model: FormModel, - emailAddress: string, + _emailAddress: string, items: DetailItem[], submitResponse: SubmitResponsePayload, formMetadata?: FormMetadata @@ -78,7 +78,7 @@ export class OutputService implements IOutputService { } submissionPayload.meta.userConfirmationEmail = - request.payload.userConfirmationEmailAddress + request.payload?.userConfirmationEmailAddress const messageId = await publishFormAdapterEvent(submissionPayload) logger.info( From b14cf7dd58009a6b2041483bf9f5bdd2124e1b66 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 8 Oct 2025 17:29:48 +0100 Subject: [PATCH 09/12] Changed to use custom property in meta --- src/server/services/outputService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 897310231..e77d59b58 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -77,8 +77,11 @@ export class OutputService implements IOutputService { return } - submissionPayload.meta.userConfirmationEmail = - request.payload?.userConfirmationEmailAddress + if (request.payload?.userConfirmationEmailAddress) { + submissionPayload.meta.custom = { + userConfirmationEmail: request.payload?.userConfirmationEmailAddress + } + } const messageId = await publishFormAdapterEvent(submissionPayload) logger.info( From 04f3af5d4cb65a12c37e691fa34ff8bc238bd200 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 9 Oct 2025 09:56:53 +0100 Subject: [PATCH 10/12] Upversioned plugin --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a280dc5d1..b15183baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^3.0.7", + "@defra/forms-engine-plugin": "^3.0.9", "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", @@ -2904,13 +2904,13 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.7.tgz", - "integrity": "sha512-PoxCa+V+fosjD6Y3lBL8Fl/mgwL/RDo7caiapbg9dT8DozotW2qo8ulfzTNWuK46tzyGZaDwArUJzOZCuKxCqA==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.9.tgz", + "integrity": "sha512-RRAycdYqBwjoPqDwawIEzSab2QZjtl8wtvQPvurKGDRD2ZMbAZg44LKd3X9RKRW5eztdLs+deGgJkBPVdSVhdw==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.555", + "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/package.json b/package.json index 2d7bfb227..de0f07a43 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^3.0.7", + "@defra/forms-engine-plugin": "^3.0.9", "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", From 8d0f5514d7ecc6e876b996c92b10c49be7583bdb Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 9 Oct 2025 10:10:57 +0100 Subject: [PATCH 11/12] Extra test --- src/server/services/outputService.test.js | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index e63511ea5..14789e717 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -148,6 +148,54 @@ describe('OutputService', () => { expect(publishFormAdapterEvent).toHaveBeenCalledWith(mockPayload) }) + it('successfully processes form submission and publishes event with extra custom property', async () => { + const mockPayload = { + meta: { + formId: 'form-123', + referenceNumber: 'REF-123456', + formName: 'Test Form', + notificationEmail: 'notify@example.com' + }, + data: mockItems + } + + const mockRequestWithEmail = { + ...mockRequest, + payload: { + userConfirmationEmailAddress: 'my-email@test123.com' + } + } + + mockFormatter.mockReturnValue(JSON.stringify(mockPayload)) + + await outputService.submit( + mockContext, + mockRequestWithEmail, + mockModel, + 'test@example.com', + mockItems, + mockSubmitResponse, + mockFormMetadata + ) + + expect(checkFormStatus).toHaveBeenCalledWith(mockRequest.params) + expect(getFormatter).toHaveBeenCalledWith('adapter', '1') + expect(mockFormatter).toHaveBeenCalledWith( + mockContext, + mockItems, + mockModel, + mockSubmitResponse, + { isPreview: false, state: FormStatus.Live }, + mockFormMetadata + ) + const expectedPayload = structuredClone(mockPayload) + // @ts-expect-error - dynamic property + expectedPayload.meta.custom = { + userConfirmationEmail: 'my-email@test123.com' + } + expect(publishFormAdapterEvent).toHaveBeenCalledWith(expectedPayload) + }) + it('successfully processes form submission without form metadata', async () => { const mockPayload = { meta: { From c8d8d32f025cf554f90fd48dc1e8a0d08bf83335 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 9 Oct 2025 12:04:41 +0100 Subject: [PATCH 12/12] Added comments about coercing --- .../plugins/SummaryPageWithConfirmationEmailController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index a14c32e28..61f0a03bd 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -52,12 +52,14 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr context: FormContext, h: FormResponseToolkit ) => { + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 const viewName = (this as unknown as PageController).viewName const { isForceAccess } = context // Check if this is a save-and-exit action const { action } = request.payload if (action === FormAction.SaveAndExit) { + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 return (this as unknown as QuestionPageController).handleSaveAndExit( request, context, @@ -71,6 +73,7 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr */ const { error } = schema.validate(request.payload, { abortEarly: false }) if (error || isForceAccess) { + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 context.errors = (this as unknown as QuestionPageController).getErrors( error?.details ) @@ -78,6 +81,7 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr return h.view(viewName, viewModel) } + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 return (this as unknown as SummaryPageController).handleFormSubmit( request, context,