diff --git a/package-lock.json b/package-lock.json index aaeb48d96..b15183baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^3.0.7", - "@defra/forms-model": "^3.0.552", + "@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", "@hapi/boom": "^10.0.1", @@ -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", @@ -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..de0f07a43 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^3.0.7", - "@defra/forms-model": "^3.0.552", + "@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", "@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/index.ts b/src/server/index.ts index 0071063c9..e6ec15b07 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' @@ -138,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, @@ -158,6 +162,9 @@ export const configureEnginePlugin = async ({ ? `/save-and-exit/${slug}` : `/save-and-exit/${slug}/${state}` ) + }, + controllers: { + SummaryPageWithConfirmationEmailController } } } diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts new file mode 100644 index 000000000..b32a798a4 --- /dev/null +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts @@ -0,0 +1,146 @@ +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-with-confirmation', + 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 new file mode 100644 index 000000000..61f0a03bd --- /dev/null +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -0,0 +1,111 @@ +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 } 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: Joi.string().email().allow('').messages({ + '*': 'Enter an email address in the correct format' + }) +}) + +export class SummaryPageWithConfirmationEmailController extends SummaryPageController { + viewName = 'summary-with-confirmation' + + 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 + ) => { + // 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, + 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) { + // 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 + ) + const viewModel = this.getSummaryViewModel(request, context) + 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, + 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..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: { @@ -341,7 +389,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - 'different@email.com', + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata @@ -442,7 +490,7 @@ describe('OutputService', () => { mockContext, mockRequest, mockModel, - '', + 'test@example.com', mockItems, mockSubmitResponse, mockFormMetadata diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index b46d81747..e77d59b58 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 _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) @@ -77,8 +77,13 @@ export class OutputService implements IOutputService { return } - const messageId = await publishFormAdapterEvent(submissionPayload) + if (request.payload?.userConfirmationEmailAddress) { + submissionPayload.meta.custom = { + 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/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 %}