diff --git a/package-lock.json b/package-lock.json index 3bcaa676b..ed8573057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/client-sns": "^3.1079.0", "@defra/forms-common": "^0.1.6", "@defra/forms-engine-plugin": "^5.0.0-alpha.34", - "@defra/forms-model": "^4.0.0", + "@defra/forms-model": "^4.0.3", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", "@govuk-one-login/service-header": "^5.0.0", @@ -2643,9 +2643,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-4.0.1.tgz", - "integrity": "sha512-9k8AnABUxP5OQYGf9FyVj+0mPn58245y9JRuJDC/m1EiAeMr9rk8qh2MNUCF+gQ4rcaSqTq6hc301gak7zThvg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-4.0.3.tgz", + "integrity": "sha512-AMjsZpZXDfq5t/2Mdn6USLfTsBb8NGP9gTsLA5VP1A5IBaI/T++eDL0bzjp1kU7p0AKQ3FBpL4X3p6CsarZFgg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^3.0.0", diff --git a/package.json b/package.json index cd53ba0dd..a37c7c2f6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@aws-sdk/client-sns": "^3.1079.0", "@defra/forms-common": "^0.1.6", "@defra/forms-engine-plugin": "^5.0.0-alpha.34", - "@defra/forms-model": "^4.0.0", + "@defra/forms-model": "^4.0.3", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", "@govuk-one-login/service-header": "^5.0.0", diff --git a/src/server/constants.js b/src/server/constants.js index 4c9579fed..1a88ff244 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -12,8 +12,19 @@ export const SAVE_AND_EXIT_PAYLOAD = 'SAVE_AND_EXIT_PAYLOAD' export const EN_GB = 'en-GB' export const CY = 'cy' +// Keys for custom meta in form submission output +export const SESSION_ACTION = '__action' +export const SESSION_ACTION_REFERENCE_NUMBER = '__referenceNumber' + +/** + * @type {{ Copy: 'copy' }} + */ +export const SessionActions = { + Copy: 'copy' +} + /** - * @type {{ Processing: 'processing', InProgress: 'inProgress', Submitted: 'submitted', Expired: 'expired', Deleted: 'deleted' }} + * @type {{ Processing: 'processing', InProgress: 'inProgress', Submitted: 'submitted', Expired: 'expired', Deleted: 'deleted' }} */ export const FormRowStatus = { Processing: 'processing', diff --git a/src/server/i18n/translations/cy.json b/src/server/i18n/translations/cy.json index 4b5674474..99cf2da34 100644 --- a/src/server/i18n/translations/cy.json +++ b/src/server/i18n/translations/cy.json @@ -297,6 +297,7 @@ "actions": "Camau", "continue": "Parhau", "delete": "Dileu", + "copy": "Copïo", "savedOn": "wedi'i gadw ar [[date]]" }, "header": { @@ -333,6 +334,42 @@ "confirmDeleteValidationMessage": "Dewiswch 'Iawn' i ddileu'r drafft neu 'Na' i'w gadw", "successNotification": "Dileuwyd y drafft" }, + "confirmCopyForm": { + "title": "A ydych chi'n siŵr eich bod am gopïo'r atebion o'r ffurflen hon?", + "info1": "Byddwn yn defnyddio'r atebion o [[formTitle]] i gychwyn ffurflen newydd. Gallwch newid unrhyw un o'r atebion cyn i chi eu cyflwyno.", + "info2": "Ni fydd cyflwyniad y ffurflen hon yn cael ei newid na'i ddisodli.", + "formNameLabel": "Enw'r ffurflen", + "formSubmittedLabel": "Cyflwynwyd ar", + "formReferenceLabel": "Cyfeirnod", + "continueButton": "Copïo'r atebion", + "cancelButton": "Canslo" + }, + "confirmCopyFormChanged": { + "title": "Eich cyflwyniad blaenorol", + "info1": "Mae'r ffurflen newydd hon yn seiliedig ar gyflwyniad blaenorol.", + "formNameLabel": "Enw'r ffurflen", + "formSubmittedLabel": "Cyflwynwyd ar", + "formReferenceLabel": "Cyfeirnod", + "subHeading": "Mae angen i chi ateb cwestiynau newydd", + "info2": "Ers i chi gyflwyno'r ffurflen hon ddiwethaf, mae rhai cwestiynau wedi newid neu mae angen i chi eu hateb eto.", + "info3": "Unwaith y byddwch wedi ateb y cwestiynau hyn, byddwch yn gallu:", + "info4": [ + "adolygu eich holl atebion gyda'i gilydd", + "gwneud newidiadau os oes angen", + "cyflwyno'r ffurflen" + ], + "continueButton": "Parhau", + "cancelButton": "Canslo" + }, + "confirmCopyCheckAnswers": { + "title": "Cafodd yr atebion hyn eu copïo o'ch cyflwyniad blaenorol", + "formNameLabel": "Ffurflen", + "formReferenceLabel": "Rhif cyfeirnod blaenorol", + "info1": "Rydym wedi copïo eich atebion i'r ffurflen newydd hon. Ni fydd unrhyw newidiadau a wnewch yn effeithio ar eich cyflwyniad blaenorol.", + "info2": "Ar y dudalen nesaf, gallwch wirio a newid eich atebion cyn cyflwyno'r ffurflen.", + "continueButton": "Parhau", + "cancelButton": "Canslo" + }, "layout": { "errorPrefix": "Gwall:", "skipLink": "Neidio i'r prif gynnwys", diff --git a/src/server/i18n/translations/en-GB.json b/src/server/i18n/translations/en-GB.json index 3ab3d92b6..c8aebb424 100644 --- a/src/server/i18n/translations/en-GB.json +++ b/src/server/i18n/translations/en-GB.json @@ -297,6 +297,7 @@ "actions": "Actions", "continue": "Continue", "delete": "Delete", + "copy": "Copy", "savedOn": "saved on [[date]]" }, "header": { @@ -333,6 +334,42 @@ "confirmDeleteValidationMessage": "Select ‘Yes’ to delete the draft or ‘No’ to keep it", "successNotification": "Draft deleted" }, + "confirmCopyForm": { + "title": "Are you sure you want to copy answers from this form?", + "info1": "We’ll use the answers from [[formTitle]] to start a new form. You can change any of the answers before you submit it.", + "info2": "This form submission will not be changed or replaced.", + "formNameLabel": "Form name", + "formSubmittedLabel": "Submitted on", + "formReferenceLabel": "Reference", + "continueButton": "Copy answers", + "cancelButton": "Cancel" + }, + "confirmCopyFormChanged": { + "title": "Your previous submission", + "info1": "This new form is based on a previous submission.", + "formNameLabel": "Form name", + "formSubmittedLabel": "Submitted on", + "formReferenceLabel": "Reference", + "subHeading": "You need to answer new questions", + "info2": "Since you last submitted this form, some questions have changed or need to be answered again.", + "info3": "Once you’ve answered these questions, you’ll be able to:", + "info4": [ + "review all your answers together", + "make changes if you need to", + "submit the form" + ], + "continueButton": "Continue", + "cancelButton": "Cancel" + }, + "confirmCopyCheckAnswers": { + "title": "These answers were copied from your previous submission", + "formNameLabel": "Form", + "formReferenceLabel": "Previous reference number", + "info1": "We’ve copied your answers into this new form. Any changes you make will not affect your previous submission.", + "info2": "On the next page, you can check and change your answers before submitting the form.", + "continueButton": "Continue", + "cancelButton": "Cancel" + }, "layout": { "errorPrefix": "Error: ", "skipLink": "Skip to main content", diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 7f1f5625d..5b196726e 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -33,6 +33,7 @@ import { returnUrlSchema } from '~/src/server/models/common.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { authRoutes, + copyFormRoutes, deleteFormRoutes, healthRoute, homepageRoutes, @@ -80,6 +81,7 @@ export default { server.route(authRoutes as ServerRoute[]) server.route(homepageRoutes as ServerRoute[]) server.route(deleteFormRoutes as ServerRoute[]) + server.route(copyFormRoutes as ServerRoute[]) } server.route(routes) diff --git a/src/server/routes/copy-form.js b/src/server/routes/copy-form.js new file mode 100644 index 000000000..c8bb7df4a --- /dev/null +++ b/src/server/routes/copy-form.js @@ -0,0 +1,462 @@ +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { + ComponentType, + ControllerPath, + hasFormComponents +} from '@defra/forms-model' +import Boom from '@hapi/boom' +import Joi from 'joi' + +import { CITIZEN_AUTH_ROUTE_OPTIONS } from '~/src/server/auth/route-options.js' +import { + SESSION_ACTION, + SESSION_ACTION_REFERENCE_NUMBER, + SessionActions +} from '~/src/server/constants.js' +import { formatDateTime } from '~/src/server/helpers/date-helper.js' +import { + constructHomepageLink, + getFormHref +} from '~/src/server/helpers/route-helpers.js' +import { t } from '~/src/server/i18n/index.js' +import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' +import { + getFormDefinition, + getFormMetadataById +} from '~/src/server/services/formsService.js' +import { getSubmissionRecord } from '~/src/server/services/submissionService.js' +import { mapValueToState } from '~/src/server/utils/map-submission-to-state.js' +import { getAllLanguages } from '~/src/server/utils/utils.js' + +export const CONFIRM_COPY_NAME = 'confirmCopy' + +const copyFormParamsSchema = Joi.object({ + formId: Joi.string().required(), + referenceNumber: Joi.string().required() +}).required() + +const copyFormPayloadSchema = Joi.object() + .keys({ + [CONFIRM_COPY_NAME]: Joi.boolean().required() + }) + .required() + +/** + * @param {Awaited>['translator']} translator + */ +const getTranslatorContext = (translator) => ({ + translator: { + ...translator, + languages: getAllLanguages() + } +}) + +/** + * The preview status of the submission, or undefined if a non-preview form (i.e. live) + * @param {FormSubmissionDocument} submission - the submission document to copy + * @returns {FormStatus | undefined} + */ +function getPreviewStatus(submission) { + // The preview status of the form, or undefined if a non-preview form (i.e. live) + const previewStatus = submission.meta.isPreview + ? submission.meta.status + : undefined + + return previewStatus +} + +/** + * Get the form translator for a submission + * @param {{ query: RequestQuery, yar: Yar }} request - the request + * @param {FormMetadata} metadata - the metadata of the form + * @param {FormSubmissionDocument} submission - the submission document to copy + * @returns {Promise<{ translator: Translator, language: string }>} + */ +async function getTranslator(request, metadata, submission) { + const previewStatus = getPreviewStatus(submission) + + return getFormTranslator(request, metadata, previewStatus, true) +} + +/** + * @param {CopyFormParams} params + * @param {string} accessToken + * @returns {Promise} + */ +async function getFormAndSubmission(params, accessToken) { + const { formId, referenceNumber } = params + const form = await getFormMetadataById(formId) + const submissionToCopy = await getSubmissionRecord( + referenceNumber, + accessToken + ) + + if (!submissionToCopy) { + throw Boom.notFound( + `Cannot copy form '${referenceNumber}' because it was not found` + ) + } + + return { form, referenceNumber, submissionToCopy } +} + +const getRouteOptions = { + auth: CITIZEN_AUTH_ROUTE_OPTIONS, + validate: { + params: copyFormParamsSchema + } +} + +export default [ + /** + * @satisfies {ServerRoute<{ Params: CopyFormParams }>} + */ + ({ + method: 'GET', + path: '/confirm-copy-form/{formId}/{referenceNumber}', + handler: async (request, h) => { + const { auth, params } = request + const { accessToken } = auth.credentials + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + + const { translator } = await getTranslator( + request, + form, + submissionToCopy + ) + + const previewStatus = getPreviewStatus(submissionToCopy) + const backLinkHref = constructHomepageLink(form.slug, previewStatus) + const model = copyFormViewModel( + 'confirmCopyForm.title', + backLinkHref, + form, + referenceNumber, + submissionToCopy, + translator + ) + + return h.view('copy-form/confirm-copy-form', model) + }, + options: getRouteOptions + }), + + /** + * @satisfies {ServerRoute<{ Params: CopyFormParams }>} + */ + ({ + method: 'POST', + path: '/confirm-copy-form/{formId}/{referenceNumber}', + handler: async (request, h) => { + const { auth, params } = request + const { accessToken } = auth.credentials + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + + const def = await getFormDefinitionForSubmission( + submissionToCopy, + form, + referenceNumber + ) + const { formHasDeclaration, formVersionHasChanged } = getFormDetails( + def, + submissionToCopy + ) + + if (formHasDeclaration || formVersionHasChanged) { + return h + .redirect( + `/confirm-copy-form/${form.id}/${referenceNumber}/form-changed` + ) + .takeover() + } + + await setCacheState(request, submissionToCopy, form) + + const formHref = getFormHref( + form.slug, + submissionToCopy.meta.isPreview, + submissionToCopy.meta.status + ) + const formSummaryPath = `${formHref}${ControllerPath.Summary}` + + return h.redirect(formSummaryPath) + }, + options: { + auth: CITIZEN_AUTH_ROUTE_OPTIONS, + validate: { + params: copyFormParamsSchema, + payload: copyFormPayloadSchema + } + } + }), + + /** + * @satisfies {ServerRoute<{ Params: CopyFormParams }>} + */ + ({ + method: 'GET', + path: '/confirm-copy-form/{formId}/{referenceNumber}/form-changed', + handler: async (request, h) => { + const { auth, params } = request + const { accessToken } = auth.credentials + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + + const { translator } = await getTranslator( + request, + form, + submissionToCopy + ) + + const model = copyFormViewModel( + 'confirmCopyFormChanged.title', + `/confirm-copy-form/${form.id}/${referenceNumber}`, + form, + referenceNumber, + submissionToCopy, + translator + ) + + return h.view('copy-form/confirm-copy-form-changed', model) + }, + options: getRouteOptions + }), + + /** + * @satisfies {ServerRoute<{ Params: CopyFormParams }>} + */ + ({ + method: 'GET', + path: '/confirm-copy-form/{formId}/{referenceNumber}/check-answers', + handler: async (request, h) => { + const { auth, params } = request + const { accessToken } = auth.credentials + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + + const { translator } = await getTranslator( + request, + form, + submissionToCopy + ) + + const model = copyFormViewModel( + 'confirmCopyCheckAnswers.title', + `/confirm-copy-form/${form.id}/${referenceNumber}/form-changed`, + form, + referenceNumber, + submissionToCopy, + translator + ) + + return h.view('copy-form/confirm-copy-check-answers', model) + }, + options: getRouteOptions + }), + + /** + * @satisfies {ServerRoute<{ Params: CopyFormParams }>} + */ + ({ + method: 'POST', + path: '/confirm-copy-form/{formId}/{referenceNumber}/check-answers', + handler: async (request, h) => { + const { auth, params } = request + const { accessToken } = auth.credentials + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + + const def = await getFormDefinitionForSubmission( + submissionToCopy, + form, + referenceNumber + ) + const { formVersionHasChanged } = getFormDetails(def, submissionToCopy) + + // Set state and dispatch user into the form + await setCacheState(request, submissionToCopy, form) + + // If the form version has changed, redirect to the form start page and walkthrough, + // otherwise redirect to the summary page and allow the engine to handle the walkthrough + const formHref = getFormHref( + form.slug, + submissionToCopy.meta.isPreview, + submissionToCopy.meta.status + ) + const nextHref = formVersionHasChanged + ? formHref + : `${formHref}${ControllerPath.Summary}` + + return h.redirect(nextHref) + }, + options: { + auth: CITIZEN_AUTH_ROUTE_OPTIONS, + validate: { + params: copyFormParamsSchema, + payload: copyFormPayloadSchema + } + } + }) +] + +/** + * @param {string} pageTitleKey - the page title key for translation + * @param {string} backLinkHref - the back link href + * @param {FormMetadata} form - the form metadata + * @param {string} referenceNumber - the submission reference number + * @param {FormSubmissionDocument} submission - the submission to copy + * @param {FormTranslator} translator - the form translator + * @returns {CopyFormViewModel} + */ +function copyFormViewModel( + pageTitleKey, + backLinkHref, + form, + referenceNumber, + submission, + translator +) { + const pageTitle = t(pageTitleKey, translator.language) + const backLink = { + text: t('common.back', translator.language), + href: backLinkHref + } + const submittedAt = getSubmittedAtDate(submission, translator) + + return { + pageTitle, + backLink, + form, + referenceNumber, + submittedAt, + context: getTranslatorContext(translator) + } +} + +/** + * @param {FormSubmissionDocument} submission - the submission document + * @param {FormTranslator} translator - the form translator + * @returns {string} + */ +function getSubmittedAtDate(submission, translator) { + return formatDateTime(submission.meta.timestamp.toString(), translator) +} + +/** + * Get the form definition associated with a submission + * @param {FormSubmissionDocument} submission + * @param {FormMetadata} form + * @param {string} referenceNumber + * @returns {Promise} + */ +async function getFormDefinitionForSubmission( + submission, + form, + referenceNumber +) { + const { status } = submission.meta + const def = await getFormDefinition(form.id, status) + + if (!def) { + throw Boom.notFound( + `Cannot copy form '${referenceNumber}' because the form definition (${status}) was not found` + ) + } + + return def +} + +/** + * Get diff details about the form definition and submission + * @param {FormDefinition} def + * @param {FormSubmissionDocument} submission + * @returns {{ formHasDeclaration: boolean, formVersionHasChanged: boolean }} + */ +function getFormDetails(def, submission) { + const formHasDeclaration = def.pages + .flatMap((p) => (hasFormComponents(p) ? p.components : [])) + .some((c) => c.type === ComponentType.DeclarationField) + + const latestVersionMetadata = /** @type {FormVersionMetadata | undefined} */ ( + def.metadata?.$$__formVersion + ) + + const { referenceNumber, status, versionMetadata } = submission.meta + + if (!latestVersionMetadata) { + throw Boom.badData( + `Cannot copy form '${referenceNumber}' because the latest ${status} version of the form definition doesn't contain versionMetadata` + ) + } + + if (!versionMetadata) { + throw Boom.badData( + `Cannot copy form '${referenceNumber}' because the submission doesn't contain versionMetadata` + ) + } + + const formVersionHasChanged = + latestVersionMetadata.versionNumber > versionMetadata.versionNumber + + return { formHasDeclaration, formVersionHasChanged } +} + +/** + * Set the cache state with the copied submission + * @param {Request<{ Params: CopyFormParams }>} request + * @param {FormSubmissionDocument} submissionToCopy + * @param {FormMetadata} form + */ +async function setCacheState(request, submissionToCopy, form) { + const cacheService = getCacheService(request.server) + const copiedState = mapValueToState(submissionToCopy) + const state = getPreviewStatus(submissionToCopy) + const req = { yar: request.yar, params: { slug: form.slug, state } } + + await cacheService.clearState(req) + await cacheService.setState(req, { + ...copiedState, + [SESSION_ACTION]: SessionActions.Copy, + [SESSION_ACTION_REFERENCE_NUMBER]: submissionToCopy.meta.referenceNumber, + $$__referenceNumber: undefined + }) +} + +/** + * @typedef {object} FormAndSubmission + * @property {FormMetadata} form - the form metadata + * @property {string} referenceNumber - the submission reference number + * @property {FormSubmissionDocument} submissionToCopy - the submission document to copy + */ + +/** + * @typedef {{ formId: string, referenceNumber: string }} CopyFormParams + */ + +/** + * @typedef {object} CopyFormBackLink + * @property {string} text - the link text + * @property {string} href - the link href + */ + +/** + * @typedef {object} CopyFormViewModel + * @property {string} pageTitle - the page title + * @property {CopyFormBackLink} backLink - the back link + * @property {FormMetadata} form - the form metadata + * @property {string} referenceNumber - the submission reference number + * @property {string} submittedAt - the submission date and time + * @property {{ translator: FormTranslator & { languages: ReturnType } }} context - the translator context + */ + +/** @typedef {Awaited>['translator']} FormTranslator */ + +/** + * @import { ServerRoute, Request, RequestQuery } from '@hapi/hapi' + * @import { Yar } from '@hapi/yar' + * @import { FormVersionMetadata, FormMetadata, FormDefinition, FormStatus } from '@defra/forms-model' + * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' + * @import { FormSubmissionDocument } from '~/src/server/services/submissionService.js' + */ diff --git a/src/server/routes/copy-form.test.js b/src/server/routes/copy-form.test.js new file mode 100644 index 000000000..ae7484838 --- /dev/null +++ b/src/server/routes/copy-form.test.js @@ -0,0 +1,430 @@ +import { + ComponentType, + ControllerPath, + ControllerType, + FormStatus +} from '@defra/forms-model' +import { StatusCodes } from 'http-status-codes' + +import { config } from '~/src/config/index.js' +import { createServer } from '~/src/server/index.js' +import { + getFormDefinition, + getFormMetadataById +} from '~/src/server/services/formsService.js' +import { getSubmissionRecord } from '~/src/server/services/submissionService.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/services/submissionService.js') + +const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' +const REFERENCE_NUMBER = 'ABC-123-XYZ' +const credentials = { + iss: 'http://localhost:3011', + sub: 'sub-1', + email: 'citizen@example.com', + idToken: 'header.payload.signature', + accessToken: 'access-1', + accessTokenExpiresAt: 1, + refreshToken: 'refresh-old' +} + +function buildFormDefinition({ + versionNumber = 1, + hasDeclaration = false, + hasVersionMetadata = true +} = {}) { + return { + name: '', + startPage: '/page-one', + pages: [ + { + path: '/page-one', + title: 'Page one', + section: 'section', + components: [ + ...(hasDeclaration + ? [ + { + type: ComponentType.DeclarationField, + name: 'declaration', + title: 'Declaration', + options: {}, + schema: {} + } + ] + : [ + { + type: ComponentType.TextField, + name: 'textField', + title: 'This is your first field', + hint: 'Help text', + options: {}, + schema: {} + } + ]) + ], + next: [{ path: ControllerPath.Summary }] + }, + { + title: 'Summary', + path: ControllerPath.Summary, + controller: ControllerType.Summary + } + ], + sections: [ + { + name: 'section', + title: 'Section title', + hideTitle: false + } + ], + conditions: [], + lists: [], + metadata: hasVersionMetadata + ? { + $$__formVersion: { + versionNumber + } + } + : {} + } +} + +function buildSubmission({ + status = FormStatus.Draft, + versionNumber = 1, + isPreview = false, + hasVersionMetadata = true +} = {}) { + return { + id: 'submission-1', + data: { + main: {}, + repeaters: {} + }, + meta: { + referenceNumber: REFERENCE_NUMBER, + status, + timestamp: new Date('2024-01-01T00:00:00Z'), + isPreview, + ...(hasVersionMetadata + ? { + versionMetadata: { + versionNumber + } + } + : {}) + } + } +} + +describe('Copy form routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + config.set('useSignInFeature', true) + + server = await createServer({ + enforceCsrf: false + }) + await server.initialize() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('GET /confirm-copy-form/{formId}/{referenceNumber}', () => { + test('responds with OK', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + + const options = { + method: 'GET', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + test('responds with not found when the submission does not exist', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(undefined) + + const options = { + method: 'GET', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + test('responds with OK for a preview submission', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildSubmission({ isPreview: true }) + ) + + const options = { + method: 'GET', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + }) + + describe('POST /confirm-copy-form/{formId}/{referenceNumber}', () => { + test('redirects to the changed form page when the form has a declaration', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition({ versionNumber: 1, hasDeclaration: true }) + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed` + ) + }) + + test('redirects to the summary when the copied form version matches', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition({ versionNumber: 1, hasDeclaration: false }) + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/form/my-form/summary') + }) + + test('responds with not found when the form definition does not exist', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + jest.mocked(getFormDefinition).mockResolvedValueOnce(undefined) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + test('responds with unprocessable entity when the form version metadata is missing', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition({ hasVersionMetadata: false }) + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY) + }) + + test('responds with unprocessable entity when the submission version metadata is missing', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildSubmission({ hasVersionMetadata: false }) + ) + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition() + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY) + }) + }) + + describe('GET /confirm-copy-form/{formId}/{referenceNumber}/form-changed', () => { + test('responds with OK', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + + const options = { + method: 'GET', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + }) + + describe('GET /confirm-copy-form/{formId}/{referenceNumber}/check-answers', () => { + test('responds with OK', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSubmissionRecord).mockResolvedValueOnce(buildSubmission()) + + const options = { + method: 'GET', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/check-answers`, + auth: { strategy: 'citizen-session', credentials } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + }) + + describe('POST /confirm-copy-form/{formId}/{referenceNumber}/check-answers', () => { + test('redirects to the form when the form version has changed', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildSubmission({ versionNumber: 1 }) + ) + + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition({ versionNumber: 2, hasDeclaration: false }) + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/check-answers`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/form/my-form') + }) + + test('redirects to the summary when the form version has not changed', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildSubmission() + ) + + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition() + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/check-answers`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/form/my-form/summary') + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/routes/homepage.js b/src/server/routes/homepage.js index 3f1db0148..6c729dcaf 100644 --- a/src/server/routes/homepage.js +++ b/src/server/routes/homepage.js @@ -1,6 +1,7 @@ import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { stateSchema } from '@defra/forms-engine-plugin/schema.js' import { slugSchema } from '@defra/forms-model' +import Boom from '@hapi/boom' import Joi from 'joi' import { config } from '~/src/config/index.js' @@ -18,9 +19,15 @@ import { import { sessionNames } from '~/src/server/helpers/session-names.js' import { getInflightSessionKey } from '~/src/server/routes/save-and-exit-helper.js' import { getFormTranslator } from '~/src/server/routes/save-and-exit.js' -import { getFormMetadata } from '~/src/server/services/formsService.js' +import { + getFormDefinition, + getFormMetadata +} from '~/src/server/services/formsService.js' import { getFormRecords } from '~/src/server/services/submissionService.js' -import { getAllLanguages } from '~/src/server/utils/utils.js' +import { + formRequiresAuthentication, + getAllLanguages +} from '~/src/server/utils/utils.js' const authBase = config.get('oidc.issuer') const runnerBase = config.get('baseUrl') @@ -61,6 +68,10 @@ function mapToRow(savedForm, translator, formId) { deleteUrl: status === FormRowStatus.InProgress ? `/delete-form/${formId}/${savedForm.magicLinkId}` + : undefined, + copyUrl: + status === FormRowStatus.Submitted + ? `/confirm-copy-form/${formId}/${savedForm.referenceNumber}` : undefined } } @@ -166,6 +177,11 @@ async function homepageHandler(request, h) { const previewStatus = isPreview ? state : undefined const form = await getFormMetadata(slug) + const definition = await getFormDefinition(form.id, state) + + if (!definition) { + return Boom.notFound(`Form ${form.id} state ${state} not found`) + } const { translator } = await getFormTranslator( request, @@ -192,6 +208,8 @@ async function homepageHandler(request, h) { yar.flash(sessionNames.successNotification).at(0) ) + const isAllowCopyEnabled = formRequiresAuthentication(definition) + return h.view('homepage', { notification, serviceNavigationParams: nav.serviceNavigationParams, @@ -199,6 +217,7 @@ async function homepageHandler(request, h) { savedForms: savedAndInFlightForms.map((savedForm) => mapToRow(savedForm, translator, form.id) ), + isAllowCopyEnabled, context: { translator: { // Force page to show language selector irrespective of form capability diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 668c10fb1..7441b82e3 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,6 +1,7 @@ export { default as authRoutes } from '~/src/server/routes/auth.js' export { default as homepageRoutes } from '~/src/server/routes/homepage.js' export { default as deleteFormRoutes } from '~/src/server/routes/delete-form.js' +export { default as copyFormRoutes } from '~/src/server/routes/copy-form.js' export { default as publicRoutes } from '~/src/server/routes/public.js' export { default as healthRoute } from '~/src/server/routes/health.js' export { default as saveAndExitRoutes } from '~/src/server/routes/save-and-exit.js' diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 7d9cf140a..a355b46e3 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -915,7 +915,6 @@ export default [ * @import { FormMetadata, SavedState } from '@defra/forms-model' * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' * @import { FormParams } from '@defra/forms-engine-plugin/types' - * @import { FormSubmissionState } from '@defra/forms-engine-plugin/engine/types.js' * @import { AnyRequest, CacheRequest } from '@defra/forms-engine-plugin/engine/types.js' * @import { BoomErrorCustomSaveAndExit, SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' * @import { SaveAndExitDetails } from '~/src/server/types.js' diff --git a/src/server/services/httpService.ts b/src/server/services/httpService.ts index e62dbf170..97cfa617e 100644 --- a/src/server/services/httpService.ts +++ b/src/server/services/httpService.ts @@ -33,8 +33,11 @@ export const get = (url: string, options?: RequestOptions) => { return request('get', url, options) } -export const getJson = (url: string) => { - return get(url, { json: true }) +export const getJson = ( + url: string, + options?: RequestOptions +) => { + return get(url, { ...options, json: true }) } export const post = (url: string, options: RequestOptions) => { diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index 07126ac3c..dbb1fcf1f 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -4,6 +4,11 @@ import { getFormatter } from '@defra/forms-engine-plugin/engine/outputFormatters import { FormStatus } from '@defra/forms-engine-plugin/types' import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' +import { + SESSION_ACTION, + SESSION_ACTION_REFERENCE_NUMBER, + SessionActions +} from '~/src/server/constants.js' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' import { getFormMetadataById } from '~/src/server/services/formsService.js' import { @@ -98,7 +103,16 @@ describe('OutputService', () => { mockModel = /** @type {FormModel} */ ( /** @type {unknown} */ ({ - name: 'Test Form' + name: 'Test Form', + def: { + name: 'Test Form', + pages: [], + metadata: { + options: { + allowCopy: true + } + } + } }) ) @@ -363,6 +377,81 @@ describe('OutputService', () => { }) }) + it('successfully processes form submission and publishes event when user is authenticated and action is "copy"', async () => { + const mockPayload = { + meta: { + formId: 'form-123', + referenceNumber: 'REF-123456', + formName: 'Test Form', + notificationEmail: 'notify@example.com' + }, + data: mockItems + } + + const credentials = { + sub: 'user-subject-id', + iss: 'user-issuer' + } + + const mockAuthenticatedRequest = /** @type {FormRequestPayload} */ ( + /** @type {unknown} */ ({ + params: { + formSlug: 'test-form', + path: '/test-form', + slug: 'test-form' + }, + auth: { + isAuthenticated: true, + strategy: CITIZEN_SESSION, + credentials + }, + payload: {} + }) + ) + + mockFormatter.mockReturnValue(JSON.stringify(mockPayload)) + + mockContext.state = { + [SESSION_ACTION]: SessionActions.Copy, + [SESSION_ACTION_REFERENCE_NUMBER]: 'REF-123456' + } + + await outputService.submit( + mockContext, + mockAuthenticatedRequest, + 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 + ) + expect(publishFormAdapterEvent).toHaveBeenCalledWith({ + ...mockPayload, + meta: { + ...mockPayload.meta, + auth: { + sub: credentials.sub, + issuer: credentials.iss + }, + custom: { + actionName: 'copy', + actionReferenceNumber: 'REF-123456' + } + } + }) + }) + it('handles formatter error and logs appropriately', async () => { const formatterError = new Error('Formatter failed') mockFormatter.mockImplementation(() => { diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index bebac8a52..7501f8acb 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -20,8 +20,13 @@ import { type Request } from '@hapi/hapi' import { CITIZEN_SESSION } from '~/src/server/auth/scheme.js' import { logger } from '~/src/server/common/helpers/logging/logger.js' +import { + SESSION_ACTION, + SESSION_ACTION_REFERENCE_NUMBER +} from '~/src/server/constants.js' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' import { getFormMetadataById } from '~/src/server/services/formsService.js' +import { formRequiresAuthentication } from '~/src/server/utils/utils.js' /** * Output service for handling form submission notifications @@ -98,6 +103,8 @@ export class OutputService implements IOutputService { const customMeta = {} as { userConfirmationEmail?: string magicLinkGroupId?: string + actionName?: string + actionReferenceNumber?: string } // Add user confirmation email if supplied @@ -113,18 +120,41 @@ export class OutputService implements IOutputService { customMeta.magicLinkGroupId = magicLinkGroupId } - if (Object.keys(customMeta).length > 0) { - submissionPayload.meta.custom = customMeta - } - const auth = (request as unknown as Request).auth - if (auth.isAuthenticated && auth.strategy === CITIZEN_SESSION) { + const isCitizenAuth = + auth.isAuthenticated && auth.strategy === CITIZEN_SESSION + + if (isCitizenAuth) { const credentials = auth.credentials submissionPayload.meta.auth = { sub: credentials.sub, issuer: credentials.iss } + + // Check the form requires authentication (i.e. allowCopy is true) + const requiresAuth = formRequiresAuthentication(model.def) + + if (requiresAuth) { + // Add action details if an action was performed to the form + const actionName = context.state[SESSION_ACTION] + const actionReferenceNumber = + context.state[SESSION_ACTION_REFERENCE_NUMBER] + + if ( + actionName && + typeof actionName === 'string' && + actionReferenceNumber && + typeof actionReferenceNumber === 'string' + ) { + customMeta.actionName = actionName + customMeta.actionReferenceNumber = actionReferenceNumber + } + } + } + + if (Object.keys(customMeta).length > 0) { + submissionPayload.meta.custom = customMeta } const messageId = await publishFormAdapterEvent(submissionPayload) diff --git a/src/server/services/submissionService.js b/src/server/services/submissionService.js index 1742b86b2..24afd59bd 100644 --- a/src/server/services/submissionService.js +++ b/src/server/services/submissionService.js @@ -206,6 +206,26 @@ export async function deleteSavedFormState(accessToken, magicLinkId) { return /** @type {{ matched: true, modified: boolean }} */ (payload) } +/** + * Gets a submission record + * @param { string } referenceNumber - the submission reference number + * @param {string} accessToken - the user access token + */ +export async function getSubmissionRecord(referenceNumber, accessToken) { + const getJsonByType = /** @type {typeof getJson} */ ( + getJson + ) + + const result = await getJsonByType( + `${submissionUrl}/submission/${referenceNumber}`, + { + headers: { authorization: `Bearer ${accessToken}` } + } + ) + + return result.payload +} + /** * The state of one saved form, as forms-submission-api returns it. * @typedef {object} SavedFormState @@ -219,6 +239,11 @@ export async function deleteSavedFormState(accessToken, magicLinkId) { */ /** + * @typedef {FormAdapterSubmissionMessagePayload & { recordCreatedAt: Date, expireAt: Date }} FormSubmissionDocument + */ + +/** + * @import { FormAdapterSubmissionMessagePayload } from '@defra/forms-engine-plugin/engine/types.js' * @import { FormStatus, SubmitPayload, SubmitResponsePayload } from '@defra/forms-model' * @import { GenerateReferenceNumber, SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' * @import { FormRowStatusType } from '~/src/server/constants.js' diff --git a/src/server/services/submissionService.test.js b/src/server/services/submissionService.test.js index 3d34a210e..7f606d31d 100644 --- a/src/server/services/submissionService.test.js +++ b/src/server/services/submissionService.test.js @@ -13,6 +13,7 @@ import { getFormRecords, getSaveAndExitDetails, getSavedFormState, + getSubmissionRecord, persistFiles, submit, validateSaveAndExitCredentials @@ -334,6 +335,33 @@ describe('Submission service', () => { ).rejects.toThrow('Could not delete the saved form') }) }) + + describe('getSubmissionRecord', () => { + it('returns the submission record', async () => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: { + data: {}, + meta: { + referenceNumber: 'XXX-XXX-XXX' + } + } + }) + + const submission = await getSubmissionRecord( + 'XXX-XXX-XXX', + 'access-token' + ) + + expect(submission?.meta.referenceNumber).toBe('XXX-XXX-XXX') + expect(getJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/submission/XXX-XXX-XXX`, + { headers: { authorization: 'Bearer access-token' } } + ) + }) + }) }) /** diff --git a/src/server/utils/map-submission-to-state.js b/src/server/utils/map-submission-to-state.js new file mode 100644 index 000000000..bcedc1b17 --- /dev/null +++ b/src/server/utils/map-submission-to-state.js @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto' + +/** + * Handles sub fields like address.addressLine1 and date.day + * @param {Record} subfieldObject + * @param {[string, RichFormValue|null]} entry + * @returns {Record} + */ +function handleSubfields(subfieldObject, [key, value]) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const subValues = Object.entries(value).reduce((acc2, [key2, value2]) => { + if (value2 === undefined) { + return acc2 + } + return { + ...acc2, + [`${key}__${key2}`]: value2 + } + }, {}) + + return { + ...subfieldObject, + ...subValues + } + } + + if (value === undefined) { + return subfieldObject + } + + return { + ...subfieldObject, + [key]: value + } +} + +/** + * Checks if a value is a string. + * @param {unknown} value - The value to check. + * @returns {value is string} True if the value is a string. + */ +export const isString = (value) => typeof value === 'string' + +/** + * Turns a form submission document into data suitable for session state + * @param {FormSubmissionDocument} formSubmissionMessage + */ +export function mapValueToState(formSubmissionMessage) { + const mainEntries = Object.entries(formSubmissionMessage.data.main) + const main = mainEntries.reduce( + (acc, entry) => handleSubfields(acc, entry), + /** @type {Record} */ ({}) + ) + + const repeaterEntries = Object.entries(formSubmissionMessage.data.repeaters) + const repeaters = repeaterEntries.reduce((repeaterObject, [key, value]) => { + const values = value.map((repeater) => { + const reduced = Object.entries(repeater).reduce( + (acc, entry) => handleSubfields(acc, entry), + /** @type {Record} */ ({}) + ) + return { + ...reduced, + itemId: randomUUID() + } + }) + + return { + ...repeaterObject, + [key]: values + } + }, {}) + + return { + ...main, + ...repeaters + } +} + +/** + * @import { RichFormValue, FormStateValue } from '@defra/forms-engine-plugin/engine/types.js' + * @import { FormSubmissionDocument } from '~/src/server/services/submissionService.js' + */ diff --git a/src/server/utils/map-submission-to-state.test.js b/src/server/utils/map-submission-to-state.test.js new file mode 100644 index 000000000..f4f0f6ebe --- /dev/null +++ b/src/server/utils/map-submission-to-state.test.js @@ -0,0 +1,592 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' + +import { + isString, + mapValueToState +} from '~/src/server/utils/map-submission-to-state.js' + +describe('mapValueToState', () => { + it('should map adaptor v2 message to state2', () => { + /** + * @type {import('~/src/server/services/submissionService.js').FormSubmissionDocument} + */ + const message = { + meta: { + schemaVersion: 1, + timestamp: new Date('2026-11-02T13:23:27.175Z'), + referenceNumber: '6U2-AML-75L', + formName: 'Components', + formId: '6a61e01bb001f0de83297a2f', + formSlug: 'components', + status: FormStatus.Draft, + isPreview: true, + notificationEmail: 'enrique.chase@defra.gov.uk', + language: 'en-GB', + versionMetadata: { + versionNumber: 37, + createdAt: new Date('2026-10-20T14:19:38.947Z') + }, + auth: { + sub: '619bc9d1-c4b9-4562-8f66-20342a053291', + issuer: 'http://identity.127.0.0.1.sslip.io:3011' + } + }, + data: { + main: { + textField: 'Bob', + multilineTextField: 'Blah', + numberField: 2, + datePartsField: { + day: 1, + month: 1, + year: 2001 + }, + yesNoField: true, + emailAddressField: 'davidjamesstone@gmail.com', + telephoneNumberField: '+447930696579', + addressField: { + uprn: '10000001', + addressLine1: 'FOREST DENE', + addressLine2: 'FOREST HILL', + town: 'NORTHWICH', + county: 'CHESHIRE', + postcode: 'CW8 2AT' + }, + radiosField: 'soleTrader', + selectField: 910400015, + checkboxesField: ['Arabian', 'Shire', 'Race'], + monthYearField: { + month: 1, + year: 2001 + }, + autocompleteField: 'es', + eastingNorthingField: { + easting: 280930, + northing: 354169 + }, + latLongField: { + latitude: 54.5120943, + longitude: -1.4277123 + }, + osGridRefField: 'SD 60137 91097', + nationalGridFieldNumberField: 'NG 1234 5678', + geospatialField: [ + { + type: 'Feature', + properties: { + description: 'P1', + coordinateGridReference: 'SD 98001 53872', + centroidGridReference: 'SD 98001 53872' + }, + geometry: { + type: 'Point', + coordinates: [-2.0319604, 53.9809063] + }, + id: '3e16efa9-2358-4983-919d-90f876627fec' + }, + { + id: '5dccbbbf-37b1-449c-82e4-93586b8c1c6a', + type: 'Feature', + properties: { + description: 'S1', + coordinateGridReference: 'SD 82245 78282', + centroidGridReference: 'SE 05918 60473' + }, + geometry: { + coordinates: [ + [ + [-2.2736596, 54.1999867], + [-2.2626732, 53.8579746], + [-1.5156029, 53.8514945], + [-1.5925072, 54.2513671], + [-2.2736596, 54.1999867] + ] + ], + type: 'Polygon' + } + }, + { + id: '54089d33-7faf-44c5-9f42-66a600717f60', + type: 'Feature', + properties: { + description: 'L1', + coordinateGridReference: 'SD 85111 78272', + centroidGridReference: 'SE 06996 61377' + }, + geometry: { + coordinates: [ + [-2.2297143, 54.1999867], + [-1.5156029, 53.8709318], + [-1.5705346, 54.244948], + [-2.2626732, 53.8774089] + ], + type: 'LineString' + } + } + ], + SBdibf: 'Yo' + }, + repeaters: { + bRpVOv: [ + { + PULqgd: 'Dad Chase', + TKqbTU: { + day: 1, + month: 1, + year: 2001 + }, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I1P1', + coordinateGridReference: 'NY 79583 23832', + centroidGridReference: 'NY 79583 23832' + }, + geometry: { + type: 'Point', + coordinates: [-2.3176049, 54.6092417] + }, + id: '3daa8d71-562a-4bfc-b82e-e65c4245083b' + }, + { + id: 'b540809d-4340-46fc-9519-47730c3ff201', + type: 'Feature', + properties: { + description: 'I1S1', + coordinateGridReference: 'NY 48570 42452', + centroidGridReference: 'NY 75562 23765' + }, + geometry: { + coordinates: [ + [ + [-2.8010033, 54.7743366], + [-2.2626732, 54.2513671], + [-2.0759057, 54.7996763], + [-2.8010033, 54.7743366] + ] + ], + type: 'Polygon' + } + }, + { + id: '655057c3-f807-4f54-a3d3-ec50ccf694f0', + type: 'Feature', + properties: { + description: 'I1L1', + coordinateGridReference: 'SE 32658 87028', + centroidGridReference: 'SE 28298 76893' + }, + geometry: { + coordinates: [ + [-1.4999451, 54.2778594], + [-1.6357526, 54.096222] + ], + type: 'LineString' + } + } + ], + MwZpks: { + easting: 377416, + northing: 516048 + } + }, + { + PULqgd: 'Mum Chase', + TKqbTU: { + day: 1, + month: 1, + year: 2001 + }, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I2P1', + coordinateGridReference: 'NY 91648 24502', + centroidGridReference: 'NY 91648 24502' + }, + geometry: { + type: 'Point', + coordinates: [-2.1308373, 54.6156039] + }, + id: 'e65f5b56-55f2-4bfe-92ae-fb49686f32a9' + }, + { + id: '5b8e3ab6-a232-4f96-8029-eebb8996d9b3', + type: 'Feature', + properties: { + description: 'I2L1', + coordinateGridReference: 'SE 04457 83967', + centroidGridReference: 'SE 04799 57308' + }, + geometry: { + coordinates: [ + [-1.9330834, 54.2513671], + [-2.469669, 53.9729062], + [-1.3820225, 53.8110477] + ], + type: 'LineString' + } + } + ], + MwZpks: { + easting: 384534, + northing: 518854 + } + }, + { + PULqgd: 'Child Chase', + TKqbTU: { + day: 1, + month: 1, + year: 2001 + }, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I3P1', + coordinateGridReference: 'SD 57949 86300', + centroidGridReference: 'SD 57949 86300' + }, + geometry: { + type: 'Point', + coordinates: [-2.6471947, 54.2706182] + }, + id: 'd644e928-4eff-40c8-a489-4274ce74525a' + }, + { + id: '7eabb7c6-6358-49c3-8f25-8cc01f0e39e6', + type: 'Feature', + properties: { + description: 'I3S1', + coordinateGridReference: 'NY 50636 37489', + centroidGridReference: 'NY 78406 24962' + }, + geometry: { + coordinates: [ + [ + [-2.7680443, 54.7299541], + [-2.4054955, 54.3347238], + [-1.8342064, 54.7933429], + [-2.7680443, 54.7299541] + ] + ], + type: 'Polygon' + } + } + ], + MwZpks: { + easting: 364853, + northing: 453287 + } + } + ] + }, + files: { + fileUploadField: [ + { + fileId: 'e30cacfe-7811-4e28-bbc8-173f4b6f84e8', + fileName: 'space.jpeg', + userDownloadLink: + 'http://localhost:3000/file-download/e30cacfe-7811-4e28-bbc8-173f4b6f84e8' + } + ] + } + }, + result: { + files: { + main: '86682bb3-22ec-42d0-bd98-7582fdc98dd8', + repeaters: { + bRpVOv: 'fe86f142-3cd9-4f65-bae2-239ccfdfc80b' + } + } + }, + conditionEvaluations: [], + recordCreatedAt: new Date('2026-11-02T13:23:28.461Z'), + expireAt: new Date('2028-05-02T12:23:28.461Z') + } + + expect(mapValueToState(message)).toEqual({ + textField: 'Bob', + multilineTextField: 'Blah', + numberField: 2, + datePartsField__day: 1, + datePartsField__month: 1, + datePartsField__year: 2001, + yesNoField: true, + emailAddressField: 'davidjamesstone@gmail.com', + telephoneNumberField: '+447930696579', + addressField__uprn: '10000001', + addressField__addressLine1: 'FOREST DENE', + addressField__addressLine2: 'FOREST HILL', + addressField__town: 'NORTHWICH', + addressField__county: 'CHESHIRE', + addressField__postcode: 'CW8 2AT', + radiosField: 'soleTrader', + selectField: 910400015, + checkboxesField: ['Arabian', 'Shire', 'Race'], + monthYearField__month: 1, + monthYearField__year: 2001, + autocompleteField: 'es', + eastingNorthingField__easting: 280930, + eastingNorthingField__northing: 354169, + latLongField__latitude: 54.5120943, + latLongField__longitude: -1.4277123, + osGridRefField: 'SD 60137 91097', + nationalGridFieldNumberField: 'NG 1234 5678', + geospatialField: [ + { + type: 'Feature', + properties: { + description: 'P1', + coordinateGridReference: 'SD 98001 53872', + centroidGridReference: 'SD 98001 53872' + }, + geometry: { + type: 'Point', + coordinates: [-2.0319604, 53.9809063] + }, + id: '3e16efa9-2358-4983-919d-90f876627fec' + }, + { + id: '5dccbbbf-37b1-449c-82e4-93586b8c1c6a', + type: 'Feature', + properties: { + description: 'S1', + coordinateGridReference: 'SD 82245 78282', + centroidGridReference: 'SE 05918 60473' + }, + geometry: { + coordinates: [ + [ + [-2.2736596, 54.1999867], + [-2.2626732, 53.8579746], + [-1.5156029, 53.8514945], + [-1.5925072, 54.2513671], + [-2.2736596, 54.1999867] + ] + ], + type: 'Polygon' + } + }, + { + id: '54089d33-7faf-44c5-9f42-66a600717f60', + type: 'Feature', + properties: { + description: 'L1', + coordinateGridReference: 'SD 85111 78272', + centroidGridReference: 'SE 06996 61377' + }, + geometry: { + coordinates: [ + [-2.2297143, 54.1999867], + [-1.5156029, 53.8709318], + [-1.5705346, 54.244948], + [-2.2626732, 53.8774089] + ], + type: 'LineString' + } + } + ], + SBdibf: 'Yo', + bRpVOv: [ + { + PULqgd: 'Dad Chase', + TKqbTU__day: 1, + TKqbTU__month: 1, + TKqbTU__year: 2001, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I1P1', + coordinateGridReference: 'NY 79583 23832', + centroidGridReference: 'NY 79583 23832' + }, + geometry: { + type: 'Point', + coordinates: [-2.3176049, 54.6092417] + }, + id: '3daa8d71-562a-4bfc-b82e-e65c4245083b' + }, + { + id: 'b540809d-4340-46fc-9519-47730c3ff201', + type: 'Feature', + properties: { + description: 'I1S1', + coordinateGridReference: 'NY 48570 42452', + centroidGridReference: 'NY 75562 23765' + }, + geometry: { + coordinates: [ + [ + [-2.8010033, 54.7743366], + [-2.2626732, 54.2513671], + [-2.0759057, 54.7996763], + [-2.8010033, 54.7743366] + ] + ], + type: 'Polygon' + } + }, + { + id: '655057c3-f807-4f54-a3d3-ec50ccf694f0', + type: 'Feature', + properties: { + description: 'I1L1', + coordinateGridReference: 'SE 32658 87028', + centroidGridReference: 'SE 28298 76893' + }, + geometry: { + coordinates: [ + [-1.4999451, 54.2778594], + [-1.6357526, 54.096222] + ], + type: 'LineString' + } + } + ], + MwZpks__easting: 377416, + MwZpks__northing: 516048, + itemId: expect.any(String) + }, + { + PULqgd: 'Mum Chase', + TKqbTU__day: 1, + TKqbTU__month: 1, + TKqbTU__year: 2001, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I2P1', + coordinateGridReference: 'NY 91648 24502', + centroidGridReference: 'NY 91648 24502' + }, + geometry: { + type: 'Point', + coordinates: [-2.1308373, 54.6156039] + }, + id: 'e65f5b56-55f2-4bfe-92ae-fb49686f32a9' + }, + { + id: '5b8e3ab6-a232-4f96-8029-eebb8996d9b3', + type: 'Feature', + properties: { + description: 'I2L1', + coordinateGridReference: 'SE 04457 83967', + centroidGridReference: 'SE 04799 57308' + }, + geometry: { + coordinates: [ + [-1.9330834, 54.2513671], + [-2.469669, 53.9729062], + [-1.3820225, 53.8110477] + ], + type: 'LineString' + } + } + ], + MwZpks__easting: 384534, + MwZpks__northing: 518854, + itemId: expect.any(String) + }, + { + PULqgd: 'Child Chase', + TKqbTU__day: 1, + TKqbTU__month: 1, + TKqbTU__year: 2001, + WffjBp: [ + { + type: 'Feature', + properties: { + description: 'I3P1', + coordinateGridReference: 'SD 57949 86300', + centroidGridReference: 'SD 57949 86300' + }, + geometry: { + type: 'Point', + coordinates: [-2.6471947, 54.2706182] + }, + id: 'd644e928-4eff-40c8-a489-4274ce74525a' + }, + { + id: '7eabb7c6-6358-49c3-8f25-8cc01f0e39e6', + type: 'Feature', + properties: { + description: 'I3S1', + coordinateGridReference: 'NY 50636 37489', + centroidGridReference: 'NY 78406 24962' + }, + geometry: { + coordinates: [ + [ + [-2.7680443, 54.7299541], + [-2.4054955, 54.3347238], + [-1.8342064, 54.7933429], + [-2.7680443, 54.7299541] + ] + ], + type: 'Polygon' + } + } + ], + MwZpks__easting: 364853, + MwZpks__northing: 453287, + itemId: expect.any(String) + } + ] + }) + }) + + it('should omit undefined values and preserve other main and repeater values', () => { + const message = { + data: { + main: { + omitted: undefined, + compound: { + defined: 'value', + omitted: undefined + }, + list: ['item'], + empty: null + }, + repeaters: { + items: [ + { + omitted: undefined, + date: { + day: 1, + omitted: undefined + } + }, + {} + ] + } + } + } + + // @ts-expect-error - exercises omission of undefined values in a malformed submission + expect(mapValueToState(message)).toEqual({ + compound__defined: 'value', + list: ['item'], + empty: null, + items: [ + { + date__day: 1, + itemId: expect.any(String) + }, + { + itemId: expect.any(String) + } + ] + }) + }) + + it('should identify strings', () => { + expect(isString('filename')).toBe(true) + expect(isString(null)).toBe(false) + }) +}) diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index a661796fb..d9ea1c28c 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -3,6 +3,7 @@ import { applyTraceHeaders as applyTraceHeader } from '@defra/forms-common' import { getAvailableLanguages } from '@defra/forms-engine-plugin' +import { isAllowCopyEnabled } from '@defra/forms-model' import { config } from '~/src/config/index.js' import { EN_GB, SIGN_IN_PATH } from '~/src/server/constants.js' @@ -177,12 +178,7 @@ export function signInUrl(path) { * @returns {boolean} */ export function formRequiresAuthentication(def) { - const enablingFlags = ['allowCopy'] - const options = /** @type {Record | undefined} */ ( - def.metadata?.options - ) - - return enablingFlags.some((flag) => options?.[flag] === true) + return isAllowCopyEnabled(def) } /** diff --git a/src/server/views/copy-form/confirm-copy-check-answers.html b/src/server/views/copy-form/confirm-copy-check-answers.html new file mode 100644 index 000000000..9e7cca8f7 --- /dev/null +++ b/src/server/views/copy-form/confirm-copy-check-answers.html @@ -0,0 +1,30 @@ +{% extends "form.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block fields %} +

{{ tR('confirmCopyCheckAnswers.title') }}

+

{{tR('confirmCopyCheckAnswers.info1')}}

+ +

+ {{ tR('confirmCopyCheckAnswers.formNameLabel') }}: {{ form.title }}
+ {{ tR('confirmCopyCheckAnswers.formReferenceLabel') }}: {{ referenceNumber }} +

+ +

{{ tR('confirmCopyCheckAnswers.info1') }}

+

{{ tR('confirmCopyCheckAnswers.info2') }}

+ +
+ {{ govukButton({ + text: tR('confirmCopyCheckAnswers.continueButton'), + name: "confirmCopy", + value: "true" + }) }} + + {{ govukButton({ + text: tR('confirmCopyCheckAnswers.cancelButton'), + classes: "govuk-button govuk-button--secondary", + href: backLink.href + }) }} +
+{% endblock %} diff --git a/src/server/views/copy-form/confirm-copy-form-changed.html b/src/server/views/copy-form/confirm-copy-form-changed.html new file mode 100644 index 000000000..f0cf46dde --- /dev/null +++ b/src/server/views/copy-form/confirm-copy-form-changed.html @@ -0,0 +1,37 @@ +{% extends "form.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block fields %} +

{{ tR('confirmCopyFormChanged.title') }}

+

{{tR('confirmCopyFormChanged.info1')}}

+ +

+ {{ tR('confirmCopyForm.formNameLabel') }}: {{ form.title }}
+ {{ tR('confirmCopyForm.formSubmittedLabel') }}: {{ submittedAt }}
+ {{ tR('confirmCopyForm.formReferenceLabel') }}: {{ referenceNumber }} +

+ +

{{ tR('confirmCopyFormChanged.subHeading') }}

+ +

{{ tR('confirmCopyFormChanged.info2') }}

+

{{ tR('confirmCopyFormChanged.info3') }}

+
    + {% for item in tR('confirmCopyFormChanged.info4') %} +
  • {{ item }}
  • + {% endfor %} +
+ +
+ {{ govukButton({ + text: tR('confirmCopyFormChanged.continueButton'), + href: "./check-answers" + }) }} + + {{ govukButton({ + text: tR('confirmCopyFormChanged.cancelButton'), + classes: "govuk-button govuk-button--secondary", + href: backLink.href + }) }} +
+{% endblock %} diff --git a/src/server/views/copy-form/confirm-copy-form.html b/src/server/views/copy-form/confirm-copy-form.html new file mode 100644 index 000000000..15d0e11df --- /dev/null +++ b/src/server/views/copy-form/confirm-copy-form.html @@ -0,0 +1,49 @@ +{% extends "form.html" %} + +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block fields %} +

{{ tR('confirmCopyForm.title') }}

+

{{tR('confirmCopyForm.info1', { formTitle: form.title }) | safe}}

+ + {% call govukInsetText() %} + {{tR('confirmCopyForm.info2')}} + {% endcall %} + + {{ govukSummaryList({ + rows: [ + { + key: { + text: tR('confirmCopyForm.formNameLabel') + }, + value: { + text: form.title + } + }, + { + key: { + text: tR('confirmCopyForm.formSubmittedLabel') + }, + value: { + text: submittedAt + } + }, + { + key: { + text: tR('confirmCopyForm.formReferenceLabel') + }, + value: { + text: referenceNumber + } + } + ] + }) }} + + {{ govukButton({ + text: tR('confirmCopyForm.continueButton'), + name: "confirmCopy", + value: "true" + }) }} +{% endblock %} diff --git a/src/server/views/homepage.html b/src/server/views/homepage.html index 9181b5cd9..b1e4f4dad 100644 --- a/src/server/views/homepage.html +++ b/src/server/views/homepage.html @@ -75,6 +75,10 @@

{{ pageTitle }}

{% if savedForm.deleteUrl %} {{tR('signIn.homepage.delete')}} {{ linkSuffix }} {% endif %} + + {% if isAllowCopyEnabled and savedForm.copyUrl %} + {{tR('signIn.homepage.copy')}} {{ linkSuffix }} + {% endif %} {% endset %} {% set rows = (rows.push([ diff --git a/test/auth/homepage.test.js b/test/auth/homepage.test.js index 95116436e..d2d5fa8a0 100644 --- a/test/auth/homepage.test.js +++ b/test/auth/homepage.test.js @@ -83,6 +83,14 @@ const savedForms = [ createdAt: '2026-09-09T09:00:00.000Z', expireAt: '2026-10-07T09:00:00.000Z', status: FormRowStatus.InProgress + }, + { + magicLinkId: 'link-3', + referenceNumber: 'BBB-222', + formTitle: 'test-form', + createdAt: '2026-11-09T09:00:00.000Z', + expireAt: '2026-12-07T09:00:00.000Z', + status: FormRowStatus.Submitted } ] @@ -159,6 +167,7 @@ describe('per-form homepage', () => { beforeEach(() => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + jest.mocked(getFormDefinition).mockResolvedValue(fixtures.form.definition) jest.mocked(getFormRecords).mockResolvedValue([]) jest .mocked(client.discovery) @@ -461,6 +470,9 @@ describe('per-form homepage', () => { expect( within(table).getByRole('cell', { name: 'AAA-111' }) ).toBeInTheDocument() + expect( + within(table).getByRole('cell', { name: 'BBB-222' }) + ).toBeInTheDocument() // 09:00 UTC is 10:00am in British Summer Time expect( @@ -572,7 +584,7 @@ describe('per-form homepage', () => { undefined ) expect(mockYarGet).toHaveBeenCalledWith('inFlightForms:test-form:') - expect(allForms).toHaveLength(3) + expect(allForms).toHaveLength(4) expect(allForms[0].referenceNumber).toBe('PRO-222') expect(allForms[0].status).toBe(FormRowStatus.Processing) }) @@ -596,7 +608,7 @@ describe('per-form homepage', () => { 'test-form', FormStatus.Draft ) - expect(allForms).toHaveLength(2) + expect(allForms).toHaveLength(3) expect(allForms[0].referenceNumber).toBe('PRO-222') expect(allForms[0].status).toBe(FormRowStatus.InProgress) expect(mockYarGet).toHaveBeenCalledWith('inFlightForms:test-form:draft') @@ -624,7 +636,7 @@ describe('per-form homepage', () => { 'test-form', undefined ) - expect(allForms).toHaveLength(2) + expect(allForms).toHaveLength(3) expect(allForms.map((form) => form.referenceNumber)).not.toContain( 'PRO-333' )