From c70b7d19f5fe09714343f8932fb4bd053c93c2e8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:52:06 +0100 Subject: [PATCH 01/12] Export plugin schema --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a263cf193..75630b691 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "./services/*": "./.server/server/plugins/engine/services/*", "./engine/*": "./.server/server/plugins/engine/*", "./helpers.js": "./.server/server/plugins/engine/components/helpers.js", + "./schema.js": "./.server/server/schemas/index.js", "./templates/*": "./.server/server/plugins/engine/views/*", "./package.json": "./package.json" }, From cd6d150a0766fa3d926cdf4d693d24e8cdb7e4e1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:52:40 +0100 Subject: [PATCH 02/12] Update save and exit options --- src/server/plugins/engine/options.js | 6 +----- src/server/plugins/engine/options.test.js | 8 ++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index e73035738..bd620753e 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -20,11 +20,7 @@ const pluginRegistrationOptionsSchema = Joi.object({ preparePageEventRequestOptions: Joi.function().optional(), onRequest: Joi.function().optional(), baseUrl: Joi.string().uri().required(), - saveAndExit: Joi.object({ - keyGenerator: Joi.function(), - sessionHydrator: Joi.function(), - sessionPersister: Joi.function() - }).optional() + saveAndExit: Joi.function().optional() }) /** diff --git a/src/server/plugins/engine/options.test.js b/src/server/plugins/engine/options.test.js index 49559840e..8d4153d6e 100644 --- a/src/server/plugins/engine/options.test.js +++ b/src/server/plugins/engine/options.test.js @@ -19,7 +19,7 @@ describe('validatePluginOptions', () => { expect(validatePluginOptions(validOptions)).toEqual(validOptions) }) - it('accepts optional properties keyGenerator, sessionHydrator, and sessionPersister', () => { + it('accepts optional property saveAndExit', () => { /** * @type {PluginOptions} */ @@ -32,11 +32,7 @@ describe('validatePluginOptions', () => { return { hello: 'world' } }, baseUrl: 'http://localhost:3009', - saveAndExit: { - keyGenerator: () => 'test-key', - sessionHydrator: () => Promise.resolve({ someState: 'value' }), - sessionPersister: () => Promise.resolve(undefined) - } + saveAndExit: (request, h) => h.redirect('/save-and-exit') } expect(validatePluginOptions(validOptionsWithOptionals)).toEqual( From 2b2c05f493ed9ad58aafeec30b531e1f2846e536 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:53:58 +0100 Subject: [PATCH 03/12] Update save and exit configuration options --- src/server/index.test.ts | 39 -------- .../QuestionPageController.test.ts | 97 +++---------------- .../pageControllers/QuestionPageController.ts | 21 ++-- .../pageControllers/__stubs__/server.ts | 9 +- src/server/plugins/engine/plugin.ts | 8 +- src/server/plugins/engine/types.ts | 20 ++-- 6 files changed, 36 insertions(+), 158 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 4d8808f30..79c1888c3 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -639,42 +639,3 @@ describe('prepareEnvironment', () => { ) }) }) - -describe('Exit route handlers', () => { - let server: Server - - beforeAll(async () => { - server = await createServer({ - services: defaultServices - }) - await server.initialize() - }) - - afterAll(async () => { - await server.stop() - }) - - beforeEach(() => { - jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - server.app.models.clear() - }) - - test('GET /exit returns 200 with exit page content', async () => { - jest.mocked(getFormMetadata).mockResolvedValueOnce({ - ...fixtures.form.metadata, - live: fixtures.form.state - }) - - jest.mocked(getFormDefinition).mockResolvedValue(fixtures.form.definition) - - const options = { - method: 'GET', - url: `${FORM_PREFIX}/slug/exit` - } - - const res = await server.inject(options) - - expect(res.statusCode).toBe(StatusCodes.OK) - expect(res.result).toContain('Your progress has been saved') - }) -}) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index c9214e5fe..a72505d9b 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -1,7 +1,6 @@ import { type PageQuestion } from '@defra/forms-model' import { type ResponseToolkit } from '@hapi/hapi' -import { getCacheService } from '~/src/server/plugins/engine/helpers.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -1333,8 +1332,8 @@ describe('Save and Exit functionality', () => { }) describe('handleSaveAndExit', () => { - it('should save state and redirect to exit page', async () => { - const sessionPersisterMock = jest.fn() + it('should invoke saveAndExit plugin option', () => { + const saveAndExitMock = jest.fn(() => ({})) const state: FormSubmissionState = { $$__referenceNumber: 'foobar', yesNoField: true @@ -1344,9 +1343,7 @@ describe('Save and Exit functionality', () => { server: { plugins: { 'forms-engine-plugin': { - saveAndExit: { - sessionPersister: sessionPersisterMock - }, + saveAndExit: saveAndExitMock, cacheService: { clearState: jest.fn() } as unknown as CacheService @@ -1357,19 +1354,15 @@ describe('Save and Exit functionality', () => { payload: { yesNoField: true, action: 'save-and-exit' } } as unknown as FormRequestPayload - const cacheService = getCacheService(request.server) - const context = model.getFormContext(request, state) - await controller1.handleSaveAndExit(request, context, h) + controller1.handleSaveAndExit(request, context, h) - expect(sessionPersisterMock).toHaveBeenCalledWith(context.state, request) - expect(cacheService.clearState).toHaveBeenCalledWith(request) - expect(h.redirect).toHaveBeenCalledWith('/test/exit') + expect(saveAndExitMock).toHaveBeenCalledWith(request, h, context) }) - it('should throw if sessionPersister inside saveAndExit options provided', async () => { - const sessionPersisterMock = jest.fn() + it('should throw if saveAndExit option not provided', () => { + const saveAndExitMock = jest.fn() const state: FormSubmissionState = { $$__referenceNumber: 'foobar', yesNoField: true @@ -1379,8 +1372,8 @@ describe('Save and Exit functionality', () => { server: { plugins: { 'forms-engine-plugin': { - // No sessionPersister object - saveAndExit: {} + // No function + saveAndExit: undefined } } }, @@ -1390,71 +1383,11 @@ describe('Save and Exit functionality', () => { const context = model.getFormContext(request, state) - await expect( - controller1.handleSaveAndExit(request, context, h) - ).rejects.toThrow('Server misconfigured for save and exit') - - expect(sessionPersisterMock).not.toHaveBeenCalled() - expect(h.redirect).not.toHaveBeenCalled() - }) - - it('should throw if no saveAndExit options provided', async () => { - const sessionPersisterMock = jest.fn() - const state: FormSubmissionState = { - $$__referenceNumber: 'foobar', - yesNoField: true - } - const request = { - ...requestPage1, - server: { - plugins: { - 'forms-engine-plugin': { - // No saveAndExit object - } - } - }, - method: 'post', - payload: { yesNoField: true, action: 'save-and-exit' } - } as unknown as FormRequestPayload - - const context = model.getFormContext(request, state) - - await expect( - controller1.handleSaveAndExit(request, context, h) - ).rejects.toThrow('Server misconfigured for save and exit') - - expect(sessionPersisterMock).not.toHaveBeenCalled() - expect(h.redirect).not.toHaveBeenCalled() - }) - - it('should throw if sessionPersister throws as well with validation errors', async () => { - const sessionPersisterMock = jest.fn().mockImplementation(() => { - throw new Error('Session persister error') - }) - const state: FormSubmissionState = { $$__referenceNumber: 'foobar' } - const request = { - ...requestPage1, - method: 'post', - server: { - plugins: { - 'forms-engine-plugin': { - saveAndExit: { - sessionPersister: sessionPersisterMock - } - } - } - }, - payload: { action: 'save-and-exit' } - } as unknown as FormRequestPayload - - const context = model.getFormContext(request, state) - - await expect( - controller1.handleSaveAndExit(request, context, h) - ).rejects.toThrow('Session persister error') + expect(() => controller1.handleSaveAndExit(request, context, h)).toThrow( + 'Server misconfigured for save and exit' + ) - expect(sessionPersisterMock).toHaveBeenCalledWith(context.state, request) - expect(h.redirect).not.toHaveBeenCalledWith('/test/exit') + expect(saveAndExitMock).not.toHaveBeenCalled() }) }) @@ -1475,7 +1408,7 @@ describe('Save and Exit functionality', () => { jest.spyOn(controller1, 'getState').mockResolvedValue({}) jest .spyOn(controller1, 'handleSaveAndExit') - .mockResolvedValue(h.redirect('/test/exit')) + .mockReturnValue(h.redirect('/custom-save-and-exit')) const postHandler = controller1.makePostRouteHandler() await postHandler(request, context, h) @@ -1503,7 +1436,7 @@ describe('Save and Exit functionality', () => { jest.spyOn(controller1, 'getState').mockResolvedValue({}) jest .spyOn(controller1, 'handleSaveAndExit') - .mockResolvedValue(h.redirect('/test/exit')) + .mockReturnValue(h.redirect('/custom-save-and-exit')) jest.spyOn(controller1, 'setState').mockResolvedValue(state) const mockResponse = { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 27c0d70fa..b48f0be41 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -39,7 +39,8 @@ import { type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, - type FormRequestRefs + type FormRequestRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { actionSchema, @@ -540,28 +541,20 @@ export class QuestionPageController extends PageController { } /** - * Handle save-and-exit action by processing form data and redirecting to exit page + * Handle save-and-exit action */ - async handleSaveAndExit( + handleSaveAndExit( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) { - const { state } = context - - // Save the current state and redirect to exit page const saveAndExit = getSaveAndExitHelpers(request.server) - if (!saveAndExit?.sessionPersister) { + if (!saveAndExit) { throw Boom.internal('Server misconfigured for save and exit') } - await saveAndExit.sessionPersister(state, request) - - const cacheService = getCacheService(request.server) - await cacheService.clearState(request) - - return h.redirect(this.getHref('/exit')) + return saveAndExit(request, h, context) } /** diff --git a/src/server/plugins/engine/pageControllers/__stubs__/server.ts b/src/server/plugins/engine/pageControllers/__stubs__/server.ts index 7c758aa3f..7dbf79b10 100644 --- a/src/server/plugins/engine/pageControllers/__stubs__/server.ts +++ b/src/server/plugins/engine/pageControllers/__stubs__/server.ts @@ -17,11 +17,10 @@ export const serverWithSaveAndExit: Server = { ...server.plugins, 'forms-engine-plugin': { ...server.plugins['forms-engine-plugin'], - saveAndExit: { - keyGenerator: jest.fn().mockReturnValue('foobar'), - sessionHydrator: jest.fn().mockReturnValue({}), - sessionPersister: jest.fn().mockImplementation(() => Promise.resolve()) - } as Pick + saveAndExit: jest.fn().mockReturnValue({}) as Pick< + PluginOptions, + 'saveAndExit' + > } } } as Server // only mocking out properties we care about diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index fde0ca9df..85dff2e7d 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -8,7 +8,6 @@ import { import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { validatePluginOptions } from '~/src/server/plugins/engine/options.js' -import { getRoutes as getSaveAndExitExitRoutes } from '~/src/server/plugins/engine/routes/exit.js' import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js' import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js' import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/routes/questions.js' @@ -40,11 +39,7 @@ export const plugin = { const cacheService = new CacheService({ server, - cacheName, - options: { - keyGenerator: saveAndExit?.keyGenerator, - sessionHydrator: saveAndExit?.sessionHydrator - } + cacheName }) await registerVision(server, options) @@ -92,7 +87,6 @@ export const plugin = { ), ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions), ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions), - ...getSaveAndExitExitRoutes(getRouteOptions), ...getFileUploadStatusRoutes() ] diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 4f90d362c..4a44e62ab 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -7,7 +7,7 @@ import { type List, type Page } from '@defra/forms-model' -import { type PluginProperties, type Request } from '@hapi/hapi' +import { type PluginProperties, type ResponseObject } from '@hapi/hapi' import { type JoiExpression, type ValidationErrorItem } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' @@ -32,13 +32,12 @@ import { type FormParams, type FormRequest, type FormRequestPayload, + type FormResponseToolkit, type FormStatus } from '~/src/server/routes/types.js' import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' -type RequestType = Request | FormRequest | FormRequestPayload - /** * Form submission state stores the following in Redis: * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }` @@ -359,6 +358,12 @@ export type OnRequestCallback = ( metadata: FormMetadata ) => void +export type SaveAndExitHandler = ( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext +) => ResponseObject + export interface PluginOptions { model?: FormModel services?: Services @@ -366,14 +371,7 @@ export interface PluginOptions { cacheName?: string globals?: Record filters?: Record - saveAndExit?: { - keyGenerator: (request: RequestType) => string - sessionHydrator: (request: RequestType) => Promise - sessionPersister: ( - state: FormSubmissionState, - request: RequestType - ) => Promise - } + saveAndExit?: SaveAndExitHandler pluginPath?: string nunjucks: { baseLayoutPath: string From 4df85621c9037f81f45a4338b51d4451798a014c Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:54:39 +0100 Subject: [PATCH 04/12] Update cacheService to remove old save and exit implementation --- src/server/services/cacheService.test.ts | 118 +---------------------- src/server/services/cacheService.ts | 63 +++--------- 2 files changed, 12 insertions(+), 169 deletions(-) diff --git a/src/server/services/cacheService.test.ts b/src/server/services/cacheService.test.ts index f11c42797..0e58a9fbf 100644 --- a/src/server/services/cacheService.test.ts +++ b/src/server/services/cacheService.test.ts @@ -2,11 +2,7 @@ import { type Request, type Server } from '@hapi/hapi' import { config } from '~/src/config/index.js' import { type FormRequest } from '~/src/server/routes/types.js' -import { - ADDITIONAL_IDENTIFIER, - CacheService, - merge -} from '~/src/server/services/cacheService.js' +import { CacheService, merge } from '~/src/server/services/cacheService.js' describe('CacheService', () => { let mockServer: Partial @@ -76,63 +72,6 @@ describe('CacheService', () => { expect(result).toEqual({}) }) }) - - it('should rehydrate state using custom fetcher when cache is missed', async () => { - const rehydratedState = { rehydrated: true } - - const customFetcher = jest.fn().mockResolvedValue(rehydratedState) - - cacheService = new CacheService({ - server: mockServer as Server, - cacheName: 'test-cache', - options: { sessionHydrator: customFetcher } - }) - - const mockRequest = { - yar: { id: 'session-id' }, - params: { state: 's', slug: 'p' } - } as unknown as FormRequest - - mockCache.get - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(rehydratedState) - - const result = await cacheService.getState(mockRequest) - - expect(customFetcher).toHaveBeenCalledWith(mockRequest) - expect(mockCache.set).toHaveBeenCalledWith( - expect.objectContaining({ - segment: 'cache', - id: expect.stringContaining('session-id') - }), - rehydratedState, - config.get('sessionTimeout') - ) - expect(result).toEqual(rehydratedState) - }) - - it('should return empty object when custom fetcher returns null', async () => { - const customFetcher = jest.fn().mockResolvedValue(null) - - cacheService = new CacheService({ - server: mockServer as Server, - cacheName: 'test-cache', - options: { sessionHydrator: customFetcher } - }) - - const mockRequest = { - yar: { id: 'session-id' }, - params: { state: 's', slug: 'p' } - } as unknown as FormRequest - - mockCache.get.mockResolvedValue(null) - - const result = await cacheService.getState(mockRequest) - - expect(customFetcher).toHaveBeenCalledWith(mockRequest) - expect(mockCache.set).not.toHaveBeenCalled() - expect(result).toEqual({}) - }) }) describe('setState', () => { @@ -178,61 +117,6 @@ describe('CacheService', () => { ) }) }) - - it('should use custom key generator if provided', async () => { - const customKey = 'my-custom-key' - const customKeyGenerator = jest.fn().mockReturnValue(customKey) - - cacheService = new CacheService({ - server: mockServer as Server, - cacheName: 'test-cache', - options: { keyGenerator: customKeyGenerator } - }) - - const mockRequest = { - yar: { id: 'some-session' }, - params: { state: 'form1', slug: 'page1' } - } as unknown as FormRequest - - await cacheService.setState(mockRequest, { test: 'value' }) - - expect(mockCache.set).toHaveBeenCalledWith( - { - segment: 'cache', - id: 'my-custom-key' - }, - { test: 'value' }, - expect.any(Number) - ) - }) - - it('should append additionalIdentifier to custom key', () => { - const customKey = 'custom:key:base:' - const customKeyGenerator = jest.fn().mockReturnValue(customKey) - - cacheService = new CacheService({ - server: mockServer as Server, - cacheName: 'test-cache', - options: { keyGenerator: customKeyGenerator } - }) - - const mockRequest = { - yar: { id: 'session-id' }, - params: { state: 'formA', slug: 'step1' } - } as unknown as FormRequest - - const result = cacheService.Key( - mockRequest, - ADDITIONAL_IDENTIFIER.Confirmation - ) - - expect(result).toEqual({ - segment: 'cache', - id: 'custom:key:base::confirmation' - }) - - expect(customKeyGenerator).toHaveBeenCalledWith(mockRequest) - }) }) describe('merge', () => { diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index b8b7f391f..69c967d26 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -25,38 +25,16 @@ export class CacheService { * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer} */ cache - generateKey?: (request: Request | FormRequest | FormRequestPayload) => string - customFetcher?: ( - request: Request | FormRequest | FormRequestPayload - ) => Promise - logger: Server['logger'] - constructor({ - server, - cacheName, - options - }: { - server: Server - cacheName?: string - options?: { - keyGenerator?: ( - request: Request | FormRequest | FormRequestPayload - ) => string - sessionHydrator?: ( - request: Request | FormRequest | FormRequestPayload - ) => Promise - } - }) { - const { keyGenerator, sessionHydrator } = options ?? {} + constructor({ server, cacheName }: { server: Server; cacheName?: string }) { if (!cacheName) { server.log( 'warn', 'You are using the default hapi cache. Please provide a cache name in plugin registration options.' ) } - this.generateKey = keyGenerator ?? this.defaultKeyGenerator.bind(this) - this.customFetcher = sessionHydrator ?? undefined + this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' }) this.logger = server.logger } @@ -65,18 +43,7 @@ export class CacheService { request: Request | FormRequest | FormRequestPayload ): Promise { const key = this.Key(request) - - let cached = await this.cache.get(key) - - // If nothing in Redis, attempt to rehydrate from backend DB - if (!cached && this.customFetcher) { - const rehydrated = await this.customFetcher(request) - - if (rehydrated != null) { - await this.cache.set(key, rehydrated, config.get('sessionTimeout')) - cached = await this.getState(request) - } - } + const cached = await this.cache.get(key) return cached ?? {} } @@ -138,18 +105,6 @@ export class CacheService { request.yar.flash(key.id, message) } - private defaultKeyGenerator( - request: Request | FormRequest | FormRequestPayload - ): string { - if (!request.yar.id) { - throw new Error('No session ID found') - } - - const state = (request.params.state as string) || '' - const slug = (request.params.slug as string) || '' - return `${request.yar.id}:${state}:${slug}:` - } - /** * The key used to store user session data against. * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a` @@ -160,13 +115,17 @@ export class CacheService { request: Request | FormRequest | FormRequestPayload, additionalIdentifier?: ADDITIONAL_IDENTIFIER ) { - const baseKey = this.generateKey - ? this.generateKey(request) - : this.defaultKeyGenerator(request) + if (!request.yar.id) { + throw new Error('No session ID found') + } + + const state = (request.params.state as string) || '' + const slug = (request.params.slug as string) || '' + const key = `${request.yar.id}:${state}:${slug}:` return { segment: partition, - id: `${baseKey}${additionalIdentifier ?? ''}` + id: `${key}${additionalIdentifier ?? ''}` } } } From 2d9f4df1cb00182f3bf8939778c9b8c5d2ff20c6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:55:41 +0100 Subject: [PATCH 05/12] Update save and exit tests --- test/form/save-and-exit.test.js | 90 +++++---------------------------- 1 file changed, 12 insertions(+), 78 deletions(-) diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index a6f750ecf..d527249e1 100644 --- a/test/form/save-and-exit.test.js +++ b/test/form/save-and-exit.test.js @@ -4,7 +4,6 @@ import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { configureEnginePlugin } from '~/src/server/plugins/engine/configureEnginePlugin.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' @@ -27,15 +26,19 @@ describe('Save and Exit functionality', () => { let headers beforeAll(async () => { + /** + * @param {FormRequestPayload} request + * @param {FormResponseToolkit} h + */ + function saveAndExit(request, h) { + return h.redirect('/my-save-and-exit') + } + server = await createServer({ formFileName: 'basic.js', formFilePath: join(import.meta.dirname, 'definitions'), enforceCsrf: true, - saveAndExit: { - keyGenerator: () => 'test-key', - sessionHydrator: () => Promise.resolve({ someState: 'value' }), - sessionPersister: () => Promise.resolve(undefined) - } + saveAndExit }) await server.initialize() @@ -75,7 +78,7 @@ describe('Save and Exit functionality', () => { }) describe('Save and Exit POST functionality', () => { - it('should save form data and redirect to exit page when action is save-and-exit', async () => { + it('should save form data when action is save-and-exit', async () => { const payload = { licenceLength: '1', action: 'save-and-exit', @@ -90,7 +93,7 @@ describe('Save and Exit functionality', () => { }) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe(`${basePath}/exit`) + expect(response.headers.location).toBe('/my-save-and-exit') }) it('should continue normally when action is continue', async () => { @@ -108,32 +111,6 @@ describe('Save and Exit functionality', () => { }) expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(response.headers.location).not.toBe(`${basePath}/exit`) - }) - - it('should work correctly when no saveAndExit is provided', async () => { - const { options } = await configureEnginePlugin({ - formFileName: 'basic.js', - formFilePath: join(import.meta.dirname, 'definitions') - }) - - expect(options.saveAndExit).toBeUndefined() - - const payload = { - licenceLength: '1', - action: 'save-and-exit', - crumb: csrfToken - } - - const response = await server.inject({ - url: `${basePath}/licence`, - method: 'POST', - headers, - payload - }) - - expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe(`${basePath}/exit`) }) it('should prevent invalid form state being persisted', async () => { @@ -150,7 +127,6 @@ describe('Save and Exit functionality', () => { payload }) - expect(response.headers.location).not.toBe(`${basePath}/exit`) expect(response.statusCode).not.toBe(StatusCodes.MOVED_TEMPORARILY) // we shouldn't be redirected to the next question }) @@ -171,49 +147,6 @@ describe('Save and Exit functionality', () => { }) }) - describe('Exit page', () => { - it('should render the exit page with success message', async () => { - const { container } = await renderResponse(server, { - url: `${basePath}/exit`, - headers - }) - - const $heading = container.getByRole('heading', { - level: 1 - }) - - expect($heading).toHaveTextContent('Your progress has been saved') - }) - - it('should render the exit page with return URL when provided', async () => { - const returnUrl = 'https://example.com/return' - const { container } = await renderResponse(server, { - url: `${basePath}/exit?returnUrl=${encodeURIComponent(returnUrl)}`, - headers - }) - - const $returnButton = container.getByRole('button', { - name: 'Return to application' - }) - - expect($returnButton).toBeInTheDocument() - expect($returnButton).toHaveAttribute('href', returnUrl) - }) - - it('should not render return button when no return URL is provided', async () => { - const { container } = await renderResponse(server, { - url: `${basePath}/exit`, - headers - }) - - const $returnButton = container.queryByRole('button', { - name: 'Return to application' - }) - - expect($returnButton).not.toBeInTheDocument() - }) - }) - describe('Error handling', () => { it('should handle CSRF token validation', async () => { const payload = { @@ -252,4 +185,5 @@ describe('Save and Exit functionality', () => { /** * @import { Server } from '@hapi/hapi' + * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' */ From 0c6dbf8e7292d86dc79895cf9263def0342f2dcb Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:56:10 +0100 Subject: [PATCH 06/12] Update save and exit docs --- docs/PLUGIN_OPTIONS.md | 38 +++--- docs/features/code-based/SAVE_AND_EXIT.md | 149 +++++----------------- 2 files changed, 52 insertions(+), 135 deletions(-) diff --git a/docs/PLUGIN_OPTIONS.md b/docs/PLUGIN_OPTIONS.md index cfe7572a1..7809fe984 100644 --- a/docs/PLUGIN_OPTIONS.md +++ b/docs/PLUGIN_OPTIONS.md @@ -106,32 +106,30 @@ await server.register({ ## saveAndExit -The `saveAndExit` plugin option enables custom session handling to enable "Save and Exit" functionality. It consists of three optional functions: +The `saveAndExit` plugin option enables custom session handling to enable "Save and Exit" functionality. It is an optional route handler function that is called with the hapi request and response toolkit in addition to the last argument which is the [form context](./REQUEST_LIFECYCLE.md) of the current page from which the save and exit button was pressed: -- `keyGenerator` - Generates unique cache keys for session storage -- `sessionHydrator` - Retrieves saved session data from external sources -- `sessionPersister` - Stores session data to external systems +```ts +export type SaveAndExitHandler = ( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext +) => ResponseObject +``` ```js await server.register({ plugin, options: { - saveAndExit: { - keyGenerator: (request) => { - const { userId, applicationId } = fetchSubmissionAttributes(request) - return `${userId}:${applicationId}` - }, - - sessionHydrator: async (request) => { - // Fetch saved state from database/API - const savedState = await fetchUserSession(request) - return savedState || null - }, - - sessionPersister: async (state, request) => { - // Save state to database/API - await saveUserSession(state, request) - } + saveAndExit: ( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext + ) => { + const { params } = request + const { slug } = params + + // Redirect user to custom page to handle saving + return h.redirect(`/custom-magic-link-save-and-exit/${slug}`) } } }) diff --git a/docs/features/code-based/SAVE_AND_EXIT.md b/docs/features/code-based/SAVE_AND_EXIT.md index 6151b7eb5..2f3a61ef5 100644 --- a/docs/features/code-based/SAVE_AND_EXIT.md +++ b/docs/features/code-based/SAVE_AND_EXIT.md @@ -8,15 +8,9 @@ render_with_liquid: false # Save and Exit -The forms engine supports save and exit capabilities through the `saveAndExit` plugin option. This feature enables advanced session handling for applications that need custom session storage, retrieval, and management beyond the default in-memory Redis cache. +The forms engine supports save and exit capabilities through the `saveAndExit` plugin option. This feature enables applications to support end users saving their current answers and returning to the form at a later date. -## Overview - -- **Generate custom cache keys** for session storage, e.g. if you want to cache by user ID -- **Hydrate sessions** from external data sources (e.g. pre-filling a form when making a return journey) -- **Persist session data** to external systems for long-term storage (e.g. Saving data to return later) - -Using the above, users can save their progress and continue filling out forms later, even across different devices or browser sessions. +It does this by displaying a secondary button on each question page when the feature is enabled. When the button is clicked the form is submitted in the usual way and once the page data is validated, the provided `saveAndExit` handler is called. This is a standard hapi route handler with an additional `FormContext` parameter passed that contains the [current state of the users progression through the form](../../REQUEST_LIFECYCLE.md). > **Note:** it is your responsibility to ensure any state that exists outside of the form engine is captured upon persistence and available during hydration, e.g. file uploads via CDP. @@ -24,124 +18,54 @@ Using the above, users can save their progress and continue filling out forms la The `saveAndExit` option is configured when registering the forms engine plugin: -```js +```ts await server.register({ - plugin: formsEnginePlugin, + plugin, options: { // ... other options - saveAndExit: { - keyGenerator: (request) => string, - sessionHydrator: (request) => Promise, - sessionPersister: (state, request) => Promise - } + saveAndExit: ( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext + ): ResponseObject => {} } }) ``` -## Functions +It is down to you to provide the mechanism by which you want to store the users data and provide them a means by which they can return to it at a later data. The `saveAndExit` handler simply activates the additional button, gives you the hook point in to the framework and provides you the data you need to know where the user had progressed to. -### keyGenerator +One common approach is ask end users for their email and send them a "magic link" that they can use to return with 28 days. -**Type:** `(request: RequestType) => string` - -Generates a cache key used to store and retrieve user session state. - -```js -const keyGenerator = (request) => { - const { userId, businessId, grantId } = request.app.userContext - return `${userId}:${businessId}:${grantId}` -} ``` - -**Parameters:** - -- `request` - The Hapi request object containing user context and form parameters - -**Returns:** A string that uniquely identifies the user's session - -### sessionHydrator - -**Type:** `(request: RequestType) => Promise` - -Called when no session state is found in Redis cache. This function should fetch saved state from an external source (e.g., database, API) and return it in the same structure expected by the form engine. This will generally be the same value as provided as `state` to the `sessionPersister` function, so a user can resume their session. - -```js -const sessionHydrator = async (request) => { - const { userId, businessId, grantId } = request.app.userContext - const key = `${userId}:${businessId}:${grantId}` - - try { - const response = await fetch(`https://backend.api/state/${key}`) - if (!response.ok) return null - - const state = await response.json() - return state // Must match FormSubmissionState structure - } catch (error) { - request.logger.error('Failed to hydrate session', error) - return null - } -} -``` - -**Parameters:** - -- `request` - The Hapi request object - -**Returns:** Promise that resolves to either: - -- `FormSubmissionState` object containing the user's saved form data -- `null` if no saved state is found or an error occurs - -### sessionPersister - -**Type:** `(state: FormSubmissionState, request: RequestType) => Promise` - -Called to persist session state to an external system for long-term storage. - -```js -const sessionPersister = async (state, request) => { - const { userId, businessId, grantId } = request.app.userContext - const key = `${userId}:${businessId}:${grantId}` - try { - await fetch(`https://your-backend.api/state/${key}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(state) - }) - - request.logger.info(`Session persisted for key: ${key}`) - } catch (error) { - request.logger.error('Failed to persist session', error) - throw error +// This example shows how you can support custom UI flows to allow an end user to save their form progress and return at a later date. +// The save and exit method is called like other hapi route handlers and expects a similar return value. +// Here we're redirecting the user to another page where we might be providing a magic link or similar that the user can use to return to the form with. +await server.register({ + plugin, + options: { + saveAndExit: ( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext + ) => { + const { params } = request + const { slug } = params + const usersAnswers = context.state + + // Redirect user to custom page to handle saving + return h.redirect(`/custom-magic-link-save-and-exit/${slug}`) + } } -} +}) ``` -**Parameters:** - -- `state` - The current form submission state to be persisted -- `request` - The Hapi request object - -**Returns:** Promise that resolves when the state is successfully persisted - -## Session Flow - -The session management system works as follows: - -1. **Key Generation**: When a user accesses a form, `keyGenerator` creates a unique cache key -2. **Cache Check**: The engine checks the cache for existing session data -3. **Hydration**: If no data exists in the cache, `sessionHydrator` is called to fetch from external storage -4. **Restoration**: Retrieved data is loaded back into Redis for fast access during the session -5. **Persistence**: When users save their progress, `sessionPersister` stores data to external storage - -Notes: +## Data Structure -- The rehydrated state must include enough information to satisfy schema validation on the current or next page. -- To properly resume a session, users should be redirected to the `/summary` page. The form engine will detect if the session state is incomplete, then the user will be redirected back to the last valid page. +The `FormSubmissionState` object can be found at `context.state` and contains all the answers the user has provided so far. -## Data Structure +This is the data you'll need to save to allow users to pick up from where they left. -The `FormSubmissionState` object passed to and from session management functions contains: +TODO: How to re-hydrate a session ```typescript interface FormSubmissionState { @@ -155,8 +79,3 @@ interface FormSubmissionState { upload?: Record } ``` - -## Error Handling - -- `sessionHydrator` should return `null` if no saved state is found or if errors occur -- `sessionPersister` should throw errors if persistence fails From fe4c37974984a73a3c97e4767e85f6e72125acff Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:56:24 +0100 Subject: [PATCH 07/12] Remove old save and return exit page --- src/server/plugins/engine/routes/exit.ts | 47 ------------------------ 1 file changed, 47 deletions(-) delete mode 100644 src/server/plugins/engine/routes/exit.ts diff --git a/src/server/plugins/engine/routes/exit.ts b/src/server/plugins/engine/routes/exit.ts deleted file mode 100644 index a7c4c01ee..000000000 --- a/src/server/plugins/engine/routes/exit.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { slugSchema } from '@defra/forms-model' -import Boom from '@hapi/boom' -import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' -import Joi from 'joi' - -import { - type FormRequest, - type FormRequestRefs -} from '~/src/server/routes/types.js' - -export function getRoutes(getRouteOptions: RouteOptions) { - return [ - { - method: 'get', - path: '/{slug}/exit', - handler: ( - request: FormRequest, - h: Pick - ) => { - const { app } = request - const { model } = app - - if (!model) { - throw Boom.notFound('No model found for exit page') - } - - const returnUrl = request.query.returnUrl - - const exitViewModel = { - pageTitle: 'Your progress has been saved', - phaseTag: model.def.phaseBanner?.phase, - returnUrl - } - - return h.view('exit', exitViewModel) - }, - options: { - ...getRouteOptions, - validate: { - params: Joi.object().keys({ - slug: slugSchema - }) - } - } - } - ] -} From 5dffc2fec8094ce1f5d2079c007c6aaa840adfdf Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 10:56:45 +0100 Subject: [PATCH 08/12] Add FormResponseToolkit for convenience --- src/server/plugins/engine/types/index.ts | 3 ++- src/server/routes/types.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index 917f045db..b23ce82fe 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -74,7 +74,8 @@ export type { FormRequest, FormRequestPayload, FormRequestPayloadRefs, - FormRequestRefs + FormRequestRefs, + FormResponseToolkit } from '~/src/server/routes/types.js' export { FormAction, FormStatus } from '~/src/server/routes/types.js' diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts index 28f1dbebf..5639840fb 100644 --- a/src/server/routes/types.ts +++ b/src/server/routes/types.ts @@ -1,4 +1,8 @@ -import { type ReqRefDefaults, type Request } from '@hapi/hapi' +import { + type ReqRefDefaults, + type Request, + type ResponseToolkit +} from '@hapi/hapi' import { type FormPayload } from '~/src/server/plugins/engine/types.js' @@ -33,6 +37,7 @@ export interface FormRequestPayloadRefs extends FormRequestRefs { export type FormRequest = Request export type FormRequestPayload = Request +export type FormResponseToolkit = Pick export enum FormAction { Continue = 'continue', From 8a2a6d4b199d23cdd34526857d54c73fbb6d4fea Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 11:02:58 +0100 Subject: [PATCH 09/12] Refactor to use new FormResponseToolkit --- src/server/plugins/engine/helpers.test.ts | 5 +- src/server/plugins/engine/helpers.ts | 5 +- .../FileUploadPageController.test.ts | 8 +-- .../FileUploadPageController.ts | 8 +-- .../pageControllers/PageController.test.ts | 7 +- .../engine/pageControllers/PageController.ts | 14 ++-- .../QuestionPageController.test.ts | 10 +-- .../pageControllers/QuestionPageController.ts | 8 +-- .../pageControllers/RepeatPageController.ts | 19 +++--- .../pageControllers/StatusPageController.ts | 8 ++- .../pageControllers/SummaryPageController.ts | 9 +-- .../pageControllers/TerminalPageController.ts | 9 ++- src/server/plugins/engine/routes/index.ts | 10 ++- .../plugins/engine/routes/questions.test.ts | 64 ++++++++----------- src/server/plugins/engine/routes/questions.ts | 11 ++-- .../engine/routes/repeaters/item-delete.ts | 19 ++---- .../engine/routes/repeaters/summary.ts | 19 ++---- 17 files changed, 100 insertions(+), 133 deletions(-) diff --git a/src/server/plugins/engine/helpers.test.ts b/src/server/plugins/engine/helpers.test.ts index 632a431ca..323d645aa 100644 --- a/src/server/plugins/engine/helpers.test.ts +++ b/src/server/plugins/engine/helpers.test.ts @@ -30,7 +30,8 @@ import { import { FormAction, FormStatus, - type FormRequest + type FormRequest, + type FormResponseToolkit } from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/basic.js' import templateDefinition from '~/test/form/definitions/templates.js' @@ -47,7 +48,7 @@ type HrefFilter = (this: NunjucksContext, path: string) => string | undefined describe('Helpers', () => { let page: PageControllerClass let request: FormContextRequest - let h: Pick + let h: FormResponseToolkit beforeEach(() => { const model = new FormModel(definition, { diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index f7841acbf..948e7f730 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -33,7 +33,8 @@ import { type FormParams, type FormQuery, type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' const logger = createLogger() @@ -117,7 +118,7 @@ engine.registerFilter('answer', function (name: string) { export function proceed( request: Pick, - h: Pick, + h: FormResponseToolkit, nextUrl: string ) { const { method, payload, query } = request diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts index d48d6d682..1f5c5bcbe 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/dot-notation */ import { ComponentType, type ComponentDef } from '@defra/forms-model' -import { type ResponseToolkit } from '@hapi/hapi' import { type ValidationErrorItem, type ValidationResult } from 'joi' import { tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js' @@ -30,7 +29,8 @@ import { } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { type CacheService } from '~/src/server/services/index.js' import definition from '~/test/form/definitions/file-upload-basic.js' @@ -1040,7 +1040,7 @@ describe('FileUploadPageController', () => { } as unknown as FormRequest const context = { state } as unknown as FormContext - const h = {} as unknown as Pick + const h = {} as unknown as FormResponseToolkit const handler = controller.makeGetItemDeleteRouteHandler() @@ -1058,7 +1058,7 @@ describe('FileUploadPageController', () => { const h = { redirect: jest.fn() - } as unknown as Pick + } as unknown as FormResponseToolkit const context = { state: {} diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts index d361ccf14..cc4952f7e 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts @@ -1,6 +1,5 @@ import { ComponentType, type PageFileUpload } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ResponseToolkit } from '@hapi/hapi' import { wait } from '@hapi/hoek' import { type ValidationErrorItem } from 'joi' @@ -35,7 +34,8 @@ import { } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' const MAX_UPLOADS = 25 @@ -148,7 +148,7 @@ export class FileUploadPageController extends QuestionPageController { return ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { viewModel } = this const { params } = request @@ -183,7 +183,7 @@ export class FileUploadPageController extends QuestionPageController { return async ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { path } = this const { state } = context diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 731a26250..8b30b4e98 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -4,7 +4,10 @@ import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' -import { type FormRequest } from '~/src/server/routes/types.js' +import { + type FormRequest, + type FormResponseToolkit +} from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/basic.js' describe('PageController', () => { @@ -155,7 +158,7 @@ describe('PageController', () => { app: { model } } as FormRequest - const h: Pick = { + const h: FormResponseToolkit = { redirect: jest.fn(), view: jest.fn() } diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 04b0e5e07..0ba533749 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -6,12 +6,7 @@ import { type Section } from '@defra/forms-model' import Boom from '@hapi/boom' -import { - type Lifecycle, - type ResponseToolkit, - type RouteOptions, - type Server -} from '@hapi/hapi' +import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { @@ -30,7 +25,8 @@ import { type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, - type FormRequestRefs + type FormRequestRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' export class PageController { @@ -171,7 +167,7 @@ export class PageController { makeGetRouteHandler(): ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => ReturnType> { return (request, context, h) => { const { viewModel, viewName } = this @@ -182,7 +178,7 @@ export class PageController { makePostRouteHandler(): ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => ReturnType> { throw Boom.badRequest('Unsupported POST route handler for this page') } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index a72505d9b..172e2df1b 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -1,5 +1,4 @@ import { type PageQuestion } from '@defra/forms-model' -import { type ResponseToolkit } from '@hapi/hapi' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' @@ -16,7 +15,8 @@ import { } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { CacheService } from '~/src/server/services/cacheService.js' import conditionalReveal from '~/test/form/definitions/conditional-reveal.js' @@ -593,7 +593,7 @@ describe('QuestionPageController', () => { code: jest.fn().mockImplementation(() => response) } - const h: Pick = { + const h: FormResponseToolkit = { redirect: jest.fn().mockReturnValue(response), view: jest.fn() } @@ -1154,7 +1154,7 @@ describe('QuestionPageController V2', () => { code: jest.fn().mockImplementation(() => response) } - const h: Pick = { + const h: FormResponseToolkit = { redirect: jest.fn().mockReturnValue(response), view: jest.fn() } @@ -1313,7 +1313,7 @@ describe('Save and Exit functionality', () => { code: jest.fn().mockImplementation(() => response) } - const h: Pick = { + const h: FormResponseToolkit = { redirect: jest.fn().mockReturnValue(response), view: jest.fn() } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index b48f0be41..1ef819389 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -9,7 +9,7 @@ import { type Page } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' +import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' @@ -398,7 +398,7 @@ export class QuestionPageController extends PageController { return async ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { collection, model, viewName } = this const { evaluationState } = context @@ -493,7 +493,7 @@ export class QuestionPageController extends PageController { return async ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { collection, viewName, model } = this const { isForceAccess, state, evaluationState } = context @@ -530,7 +530,7 @@ export class QuestionPageController extends PageController { proceed( request: FormContextRequest, - h: Pick, + h: FormResponseToolkit, nextPath?: string ) { const nextUrl = nextPath diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index 5dbe86b58..3252b8fa2 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'crypto' import { type PageRepeat, type Repeat } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ResponseToolkit } from '@hapi/hapi' import Joi from 'joi' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' @@ -25,7 +24,8 @@ import { import { FormAction, type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' export class RepeatPageController extends QuestionPageController { @@ -128,10 +128,7 @@ export class RepeatPageController extends QuestionPageController { } } - proceed( - request: FormContextRequest, - h: Pick - ) { + proceed(request: FormContextRequest, h: FormResponseToolkit) { const nextPath = this.getSummaryPath(request) return super.proceed(request, h, nextPath) } @@ -151,7 +148,7 @@ export class RepeatPageController extends QuestionPageController { return async ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { path } = this const { query } = request @@ -179,7 +176,7 @@ export class RepeatPageController extends QuestionPageController { return ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { path } = this const { query } = request @@ -205,7 +202,7 @@ export class RepeatPageController extends QuestionPageController { return ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { path, repeat } = this const { query } = request @@ -269,7 +266,7 @@ export class RepeatPageController extends QuestionPageController { return ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { viewModel } = this const { state } = context @@ -304,7 +301,7 @@ export class RepeatPageController extends QuestionPageController { return async ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { repeat } = this const { state } = context diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index f5e363688..9f2f72d7e 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -1,11 +1,13 @@ import { type PageStatus } from '@defra/forms-model' -import { type ResponseToolkit } from '@hapi/hapi' import { getCacheService } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' -import { type FormRequest } from '~/src/server/routes/types.js' +import { + type FormRequest, + type FormResponseToolkit +} from '~/src/server/routes/types.js' export class StatusPageController extends QuestionPageController { declare pageDef: PageStatus @@ -24,7 +26,7 @@ export class StatusPageController extends QuestionPageController { return async ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { viewModel, viewName } = this diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 2b6b35544..97ce52b3e 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -5,7 +5,7 @@ import { type SubmitPayload } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' +import { type RouteOptions } from '@hapi/hapi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' @@ -32,7 +32,8 @@ import { import { type FormRequest, type FormRequestPayload, - type FormRequestPayloadRefs + type FormRequestPayloadRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' export class SummaryPageController extends QuestionPageController { @@ -81,7 +82,7 @@ export class SummaryPageController extends QuestionPageController { return async ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { viewName } = this @@ -102,7 +103,7 @@ export class SummaryPageController extends QuestionPageController { return async ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => { const { model } = this const { params } = request diff --git a/src/server/plugins/engine/pageControllers/TerminalPageController.ts b/src/server/plugins/engine/pageControllers/TerminalPageController.ts index e5f15a134..a9e05cc9a 100644 --- a/src/server/plugins/engine/pageControllers/TerminalPageController.ts +++ b/src/server/plugins/engine/pageControllers/TerminalPageController.ts @@ -1,10 +1,13 @@ import { type PageTerminal } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' +import { type ResponseObject } from '@hapi/hapi' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/routes/types.js' export class TerminalPageController extends QuestionPageController { declare pageDef: PageTerminal @@ -13,7 +16,7 @@ export class TerminalPageController extends QuestionPageController { makePostRouteHandler(): ( request: FormRequestPayload, context: FormContext, - h: Pick + h: FormResponseToolkit ) => Promise { throw Boom.methodNotAllowed('POST method not allowed for terminal pages') } diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index dbefdd99d..829fa21a8 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -26,12 +26,13 @@ import { } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' export async function redirectOrMakeHandler( request: FormRequest | FormRequestPayload, - h: Pick, + h: FormResponseToolkit, makeHandler: ( page: PageControllerClass, context: FormContext @@ -181,10 +182,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { return handler } -export function dispatchHandler( - request: FormRequest, - h: Pick -) { +export function dispatchHandler(request: FormRequest, h: FormResponseToolkit) { const { model } = request.app const servicePath = model ? `/${model.basePath}` : '' diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index baf8d3f6f..b530517ce 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -1,5 +1,5 @@ import Boom from '@hapi/boom' -import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' +import { type ResponseObject } from '@hapi/hapi' // eslint-disable-next-line n/no-unpublished-import import nock from 'nock' @@ -13,7 +13,8 @@ import { import { type FormContext } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormResponseToolkit } from '~/src/server/routes/types.js' jest.mock('~/src/server/plugins/engine/models/SummaryViewModel', () => ({ SummaryViewModel: class { @@ -35,7 +36,7 @@ jest.mock('~/src/server/plugins/engine/outputFormatters/machine/v1', () => ({ jest.mock('~/src/server/plugins/engine/routes/index') describe('makeGetHandler', () => { - const hMock: Pick = { + const hMock: FormResponseToolkit = { redirect: jest.fn(), view: jest.fn() } @@ -64,7 +65,7 @@ describe('makeGetHandler', () => { ( _request: FormRequest, context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { data = context.data return Promise.resolve({} as unknown as ResponseObject) @@ -81,11 +82,8 @@ describe('makeGetHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - ( - _req: FormRequest | FormRequestPayload, - _h: Pick, - fn - ) => Promise.resolve(fn(pageMock, contextMock)) + (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makeGetHandler()(requestMock, hMock) @@ -108,7 +106,7 @@ describe('makeGetHandler', () => { ( _request: FormRequest, context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { data = context.data return Promise.resolve({} as unknown as ResponseObject) @@ -127,11 +125,8 @@ describe('makeGetHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - ( - _req: FormRequest | FormRequestPayload, - _h: Pick, - fn - ) => Promise.resolve(fn(pageMock, contextMock)) + (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makeGetHandler()(requestMock, hMock) @@ -152,7 +147,7 @@ describe('makeGetHandler', () => { ( _request: FormRequest, _context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { return Promise.resolve({} as unknown as ResponseObject) } @@ -170,7 +165,7 @@ describe('makeGetHandler', () => { .mockImplementation( async ( _req: FormRequest | FormRequestPayload, - _h: Pick, + _h: FormResponseToolkit, fn ) => { try { @@ -190,7 +185,7 @@ describe('makeGetHandler', () => { }) describe('makePostHandler', () => { - const hMock: Pick = { + const hMock: FormResponseToolkit = { redirect: jest.fn(), view: jest.fn() } @@ -221,7 +216,7 @@ describe('makePostHandler', () => { ( _request: FormRequest, _context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { // do return a valid ResponseObject wrapped in Promise.resolve return mockPostResponse @@ -239,11 +234,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - ( - _req: FormRequest | FormRequestPayload, - _h: Pick, - fn - ) => Promise.resolve(fn(pageMock, contextMock)) + (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) const response = await makePostHandler()(requestMock, hMock) @@ -263,7 +255,7 @@ describe('makePostHandler', () => { ( _request: FormRequest, _context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { return Promise.resolve({} as unknown as ResponseObject) } @@ -282,11 +274,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - ( - _req: FormRequest | FormRequestPayload, - _h: Pick, - fn - ) => Promise.resolve(fn(pageMock, contextMock)) + (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makePostHandler()(requestMock, hMock) @@ -309,7 +298,7 @@ describe('makePostHandler', () => { ( _request: FormRequest, _context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { // do return a valid ResponseObject wrapped in Promise.resolve return mockPostResponse @@ -327,11 +316,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - ( - _req: FormRequest | FormRequestPayload, - _h: Pick, - fn - ) => Promise.resolve(fn(pageMock, contextMock)) + (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makePostHandler()(requestMock, hMock) @@ -352,7 +338,7 @@ describe('makePostHandler', () => { ( _request: FormRequest, _context: FormContext, - _h: Pick + _h: FormResponseToolkit ) => { return Promise.resolve({} as unknown as ResponseObject) } @@ -371,7 +357,7 @@ describe('makePostHandler', () => { .mockImplementation( async ( _req: FormRequest | FormRequestPayload, - _h: Pick, + _h: FormResponseToolkit, fn ) => { try { @@ -395,7 +381,7 @@ function createMockPageController( routeHandler: ( request: FormRequest, context: FormContext, - h: Pick + h: FormResponseToolkit ) => ResponseObject | Promise ): PageControllerClass { return { diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index dc8d83dcd..0db6c7f6c 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -2,7 +2,6 @@ import { hasFormComponents, slugSchema, type Event } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, - type ResponseToolkit, type RouteOptions, type ServerRoute } from '@hapi/hapi' @@ -32,7 +31,8 @@ import { type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, - type FormRequestRefs + type FormRequestRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { actionSchema, @@ -75,10 +75,7 @@ async function handleHttpEvent( export function makeGetHandler( preparePageEventRequestOptions?: PreparePageEventRequestOptions ) { - return function getHandler( - request: FormRequest, - h: Pick - ) { + return function getHandler(request: FormRequest, h: FormResponseToolkit) { const { params } = request if (normalisePath(params.path) === '') { @@ -116,7 +113,7 @@ export function makePostHandler( ) { return function postHandler( request: FormRequestPayload, - h: Pick + h: FormResponseToolkit ) { const { query } = request diff --git a/src/server/plugins/engine/routes/repeaters/item-delete.ts b/src/server/plugins/engine/routes/repeaters/item-delete.ts index ad18c92ae..f1d06cd40 100644 --- a/src/server/plugins/engine/routes/repeaters/item-delete.ts +++ b/src/server/plugins/engine/routes/repeaters/item-delete.ts @@ -1,10 +1,6 @@ import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' -import { - type ResponseToolkit, - type RouteOptions, - type ServerRoute -} from '@hapi/hapi' +import { type RouteOptions, type ServerRoute } from '@hapi/hapi' import Joi from 'joi' import { FileUploadPageController } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js' @@ -14,7 +10,8 @@ import { type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, - type FormRequestRefs + type FormRequestRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { actionSchema, @@ -26,10 +23,7 @@ import { } from '~/src/server/schemas/index.js' // Item delete GET route -function getHandler( - request: FormRequest, - h: Pick -) { +function getHandler(request: FormRequest, h: FormResponseToolkit) { const { params } = request return redirectOrMakeHandler(request, h, (page, context) => { @@ -46,10 +40,7 @@ function getHandler( }) } -function postHandler( - request: FormRequestPayload, - h: Pick -) { +function postHandler(request: FormRequestPayload, h: FormResponseToolkit) { const { params } = request return redirectOrMakeHandler(request, h, (page, context) => { diff --git a/src/server/plugins/engine/routes/repeaters/summary.ts b/src/server/plugins/engine/routes/repeaters/summary.ts index 557980dca..3126ed9d2 100644 --- a/src/server/plugins/engine/routes/repeaters/summary.ts +++ b/src/server/plugins/engine/routes/repeaters/summary.ts @@ -1,11 +1,7 @@ // List summary GET route import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' -import { - type ResponseToolkit, - type RouteOptions, - type ServerRoute -} from '@hapi/hapi' +import { type RouteOptions, type ServerRoute } from '@hapi/hapi' import Joi from 'joi' import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js' @@ -14,7 +10,8 @@ import { type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, - type FormRequestRefs + type FormRequestRefs, + type FormResponseToolkit } from '~/src/server/routes/types.js' import { actionSchema, @@ -23,10 +20,7 @@ import { stateSchema } from '~/src/server/schemas/index.js' -function getHandler( - request: FormRequest, - h: Pick -) { +function getHandler(request: FormRequest, h: FormResponseToolkit) { const { params } = request return redirectOrMakeHandler(request, h, (page, context) => { @@ -38,10 +32,7 @@ function getHandler( }) } -function postHandler( - request: FormRequestPayload, - h: Pick -) { +function postHandler(request: FormRequestPayload, h: FormResponseToolkit) { const { params } = request return redirectOrMakeHandler(request, h, (page, context) => { From 6f74e1acb0ff4af41bccccd7f6fac2064be6da22 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 13:27:30 +0100 Subject: [PATCH 10/12] Add and export "AnyRequest" type --- src/server/plugins/engine/types.ts | 8 +++++++- src/server/plugins/engine/types/index.ts | 1 + src/server/services/cacheService.ts | 12 ++++-------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 4a44e62ab..0da953b51 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -7,7 +7,11 @@ import { type List, type Page } from '@defra/forms-model' -import { type PluginProperties, type ResponseObject } from '@hapi/hapi' +import { + type PluginProperties, + type Request, + type ResponseObject +} from '@hapi/hapi' import { type JoiExpression, type ValidationErrorItem } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' @@ -38,6 +42,8 @@ import { import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' +export type AnyRequest = Request | FormRequest | FormRequestPayload + /** * Form submission state stores the following in Redis: * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }` diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index b23ce82fe..5ea267ced 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -1,4 +1,5 @@ export type { + AnyRequest, CheckAnswers, ErrorMessageTemplate, ErrorMessageTemplateList, diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index 69c967d26..e90252c26 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -1,9 +1,10 @@ -import { type Request, type Server } from '@hapi/hapi' +import { type Server } from '@hapi/hapi' import * as Hoek from '@hapi/hoek' import { config } from '~/src/config/index.js' import { type createServer } from '~/src/server/index.js' import { + type AnyRequest, type FormPayload, type FormState, type FormSubmissionError, @@ -39,9 +40,7 @@ export class CacheService { this.logger = server.logger } - async getState( - request: Request | FormRequest | FormRequestPayload - ): Promise { + async getState(request: AnyRequest): Promise { const key = this.Key(request) const cached = await this.cache.get(key) @@ -111,10 +110,7 @@ export class CacheService { * @param request - hapi request object * @param additionalIdentifier - appended to the id */ - Key( - request: Request | FormRequest | FormRequestPayload, - additionalIdentifier?: ADDITIONAL_IDENTIFIER - ) { + Key(request: AnyRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) { if (!request.yar.id) { throw new Error('No session ID found') } From aded4fb3a4d67baf00e5a84a4266211eab0edefa Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 13:37:01 +0100 Subject: [PATCH 11/12] Add, export and use "AnyFormRequest" type --- docs/PLUGIN_OPTIONS.md | 2 +- src/server/plugins/engine/helpers.ts | 5 +-- .../FileUploadPageController.ts | 9 ++-- .../pageControllers/QuestionPageController.ts | 15 +++---- src/server/plugins/engine/routes/index.ts | 9 ++-- .../plugins/engine/routes/questions.test.ts | 42 +++++++------------ src/server/plugins/engine/routes/questions.ts | 3 +- src/server/plugins/engine/types.ts | 5 ++- src/server/plugins/engine/types/index.ts | 1 + src/server/plugins/nunjucks/context.js | 6 +-- src/server/services/cacheService.ts | 20 ++++----- src/typings/hapi/index.d.ts | 11 +++-- 12 files changed, 53 insertions(+), 75 deletions(-) diff --git a/docs/PLUGIN_OPTIONS.md b/docs/PLUGIN_OPTIONS.md index 7809fe984..42d74ca20 100644 --- a/docs/PLUGIN_OPTIONS.md +++ b/docs/PLUGIN_OPTIONS.md @@ -80,7 +80,7 @@ If provided, the `onRequest` plugin option will be invoked on each request to an ```ts export type OnRequestCallback = ( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, params: FormParams, definition: FormDefinition, metadata: FormMetadata diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index 948e7f730..a3c2b2dac 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -23,6 +23,7 @@ import { import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { + type AnyFormRequest, type FormContext, type FormContextRequest, type FormSubmissionError @@ -32,8 +33,6 @@ import { FormStatus, type FormParams, type FormQuery, - type FormRequest, - type FormRequestPayload, type FormResponseToolkit } from '~/src/server/routes/types.js' @@ -328,7 +327,7 @@ export function getError(detail: ValidationErrorItem): FormSubmissionError { * is not disabled on the current route, and that cookies/state are present. */ export function safeGenerateCrumb( - request: FormRequest | FormRequestPayload | null + request: AnyFormRequest | null ): string | undefined { // no request or no .state if (!request?.state) { diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts index cc4952f7e..523cdfe50 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts @@ -22,6 +22,7 @@ import { import { FileStatus, UploadStatus, + type AnyFormRequest, type FeaturedFormPageViewModel, type FileState, type FormContext, @@ -111,7 +112,7 @@ export class FileUploadPageController extends QuestionPageController { return payload } - async getState(request: FormRequest | FormRequestPayload) { + async getState(request: AnyFormRequest) { const { fileUpload } = this // Get the actual state @@ -279,7 +280,7 @@ export class FileUploadPageController extends QuestionPageController { * @param state - the form state */ private async refreshUpload( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, state: FormSubmissionState ) { state = await this.checkUploadStatus(request, state) @@ -295,7 +296,7 @@ export class FileUploadPageController extends QuestionPageController { * @param depth - the number of retries so far */ private async checkUploadStatus( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, state: FormSubmissionState, depth = 1 ): Promise { @@ -417,7 +418,7 @@ export class FileUploadPageController extends QuestionPageController { * @param state - the form state */ private async initiateAndStoreNewUpload( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, state: FormSubmissionState ) { const { fileUpload, href, path } = this diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 1ef819389..478602d74 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -25,6 +25,7 @@ import { import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { + type AnyFormRequest, type FormContext, type FormContextRequest, type FormPageViewModel, @@ -182,10 +183,7 @@ export class QuestionPageController extends PageController { } } - getRelevantPath( - request: FormRequest | FormRequestPayload, - context: FormContext - ) { + getRelevantPath(request: AnyFormRequest, context: FormContext) { const { paths } = context const startPath = this.getStartPath() @@ -297,7 +295,7 @@ export class QuestionPageController extends PageController { return getErrors(details) } - async getState(request: FormRequest | FormRequestPayload) { + async getState(request: AnyFormRequest) { const { query } = request // Skip get for preview URL direct access @@ -310,10 +308,7 @@ export class QuestionPageController extends PageController { return cacheService.getState(request) } - async setState( - request: FormRequest | FormRequestPayload, - state: FormSubmissionState - ) { + async setState(request: AnyFormRequest, state: FormSubmissionState) { const { query } = request // Skip set for preview URL direct access @@ -327,7 +322,7 @@ export class QuestionPageController extends PageController { } async mergeState( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, state: FormSubmissionState, update: object ) { diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 829fa21a8..c34b2ad53 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -21,17 +21,17 @@ import { type PageControllerClass } from '~/src/server/plugins/engine/pageContro import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js' import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { + type AnyFormRequest, type FormContext, type PluginOptions } from '~/src/server/plugins/engine/types.js' import { type FormRequest, - type FormRequestPayload, type FormResponseToolkit } from '~/src/server/routes/types.js' export async function redirectOrMakeHandler( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, h: FormResponseToolkit, makeHandler: ( page: PageControllerClass, @@ -93,10 +93,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { const { formsService } = services - async function handler( - request: FormRequest | FormRequestPayload, - h: ResponseToolkit - ) { + async function handler(request: AnyFormRequest, h: ResponseToolkit) { if (server.app.model) { request.app.model = server.app.model diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index b530517ce..c9940ed5d 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -10,7 +10,10 @@ import { makeGetHandler, makePostHandler } from '~/src/server/plugins/engine/routes/questions.js' -import { type FormContext } from '~/src/server/plugins/engine/types.js' +import { + type AnyFormRequest, + type FormContext +} from '~/src/server/plugins/engine/types.js' import { type FormRequest, type FormRequestPayload, @@ -81,9 +84,8 @@ describe('makeGetHandler', () => { jest .mocked(redirectOrMakeHandler) - .mockImplementation( - (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => - Promise.resolve(fn(pageMock, contextMock)) + .mockImplementation((_req: AnyFormRequest, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makeGetHandler()(requestMock, hMock) @@ -124,9 +126,8 @@ describe('makeGetHandler', () => { jest .mocked(redirectOrMakeHandler) - .mockImplementation( - (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => - Promise.resolve(fn(pageMock, contextMock)) + .mockImplementation((_req: AnyFormRequest, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makeGetHandler()(requestMock, hMock) @@ -163,11 +164,7 @@ describe('makeGetHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - async ( - _req: FormRequest | FormRequestPayload, - _h: FormResponseToolkit, - fn - ) => { + async (_req: AnyFormRequest, _h: FormResponseToolkit, fn) => { try { await fn(pageMock, contextMock) } catch (err) { @@ -233,9 +230,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) - .mockImplementation( - (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => - Promise.resolve(fn(pageMock, contextMock)) + .mockImplementation((_req: AnyFormRequest, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) const response = await makePostHandler()(requestMock, hMock) @@ -273,9 +269,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) - .mockImplementation( - (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => - Promise.resolve(fn(pageMock, contextMock)) + .mockImplementation((_req: AnyFormRequest, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makePostHandler()(requestMock, hMock) @@ -315,9 +310,8 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) - .mockImplementation( - (_req: FormRequest | FormRequestPayload, _h: FormResponseToolkit, fn) => - Promise.resolve(fn(pageMock, contextMock)) + .mockImplementation((_req: AnyFormRequest, _h: FormResponseToolkit, fn) => + Promise.resolve(fn(pageMock, contextMock)) ) await makePostHandler()(requestMock, hMock) @@ -355,11 +349,7 @@ describe('makePostHandler', () => { jest .mocked(redirectOrMakeHandler) .mockImplementation( - async ( - _req: FormRequest | FormRequestPayload, - _h: FormResponseToolkit, - fn - ) => { + async (_req: AnyFormRequest, _h: FormResponseToolkit, fn) => { try { await fn(pageMock, contextMock) } catch (err) { diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index 0db6c7f6c..bdc9a9659 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -24,6 +24,7 @@ import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js' import { + type AnyFormRequest, type FormContext, type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js' @@ -44,7 +45,7 @@ import { import * as httpService from '~/src/server/services/httpService.js' async function handleHttpEvent( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, page: PageControllerClass, context: FormContext, event: Event, diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 0da953b51..3aee40108 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -42,7 +42,8 @@ import { import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' -export type AnyRequest = Request | FormRequest | FormRequestPayload +export type AnyFormRequest = FormRequest | FormRequestPayload +export type AnyRequest = Request | AnyFormRequest /** * Form submission state stores the following in Redis: @@ -358,7 +359,7 @@ export type PreparePageEventRequestOptions = ( ) => void export type OnRequestCallback = ( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, params: FormParams, definition: FormDefinition, metadata: FormMetadata diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index 5ea267ced..202da0b7f 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -1,4 +1,5 @@ export type { + AnyFormRequest, AnyRequest, CheckAnswers, ErrorMessageTemplate, diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index 6d39a3e50..ef5b9dfca 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -18,7 +18,7 @@ const logger = createLogger() let webpackManifest /** - * @param {FormRequest | FormRequestPayload | null} request + * @param {AnyFormRequest | null} request */ export async function context(request) { const { params, response } = request ?? {} @@ -62,7 +62,7 @@ export async function context(request) { /** * Returns the context for the devtool. Consumers won't have access to this. - * @param {FormRequest | FormRequestPayload | null} _request + * @param {AnyFormRequest | null} _request * @returns {Record & { assetPath: string, getDxtAssetPath: (asset: string) => string }} */ export function devtoolContext(_request) { @@ -97,5 +97,5 @@ export function devtoolContext(_request) { /** * @import { ViewContext } from '~/src/server/plugins/nunjucks/types.js' - * @import { FormRequest, FormRequestPayload } from '~/src/server/routes/types.js' + * @import { AnyFormRequest } from '~/src/server/plugins/engine/types.js' */ diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index e90252c26..211184b50 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -4,16 +4,13 @@ import * as Hoek from '@hapi/hoek' import { config } from '~/src/config/index.js' import { type createServer } from '~/src/server/index.js' import { + type AnyFormRequest, type AnyRequest, type FormPayload, type FormState, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' -import { - type FormRequest, - type FormRequestPayload -} from '~/src/server/routes/types.js' const partition = 'cache' @@ -47,10 +44,7 @@ export class CacheService { return cached ?? {} } - async setState( - request: FormRequest | FormRequestPayload, - state: FormSubmissionState - ) { + async setState(request: AnyFormRequest, state: FormSubmissionState) { const key = this.Key(request) const ttl = config.get('sessionTimeout') @@ -60,7 +54,7 @@ export class CacheService { } async getConfirmationState( - request: FormRequest | FormRequestPayload + request: AnyFormRequest ): Promise<{ confirmed?: true }> { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const value = await this.cache.get(key) @@ -69,7 +63,7 @@ export class CacheService { } async setConfirmationState( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, confirmationState: { confirmed?: true } ) { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) @@ -78,14 +72,14 @@ export class CacheService { return this.cache.set(key, confirmationState, ttl) } - async clearState(request: FormRequest | FormRequestPayload) { + async clearState(request: AnyFormRequest) { if (request.yar.id) { await this.cache.drop(this.Key(request)) } } getFlash( - request: FormRequest | FormRequestPayload + request: AnyFormRequest ): { errors: FormSubmissionError[] } | undefined { const key = this.Key(request) const messages = request.yar.flash(key.id) @@ -96,7 +90,7 @@ export class CacheService { } setFlash( - request: FormRequest | FormRequestPayload, + request: AnyFormRequest, message: { errors: FormSubmissionError[] } ) { const key = this.Key(request) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index 2bf9127a7..0c9405fd7 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,11 +5,10 @@ import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' -import { type PluginOptions } from '~/src/server/plugins/engine/types.ts' import { - type FormRequest, - type FormRequestPayload -} from '~/src/server/routes/types.js' + type AnyFormRequest, + type PluginOptions +} from '~/src/server/plugins/engine/types.ts' import { type CacheService } from '~/src/server/services/index.js' declare module '@hapi/hapi' { @@ -17,13 +16,13 @@ declare module '@hapi/hapi' { // props from plugins which doesn't export @types interface PluginProperties { crumb: { - generate?: (request: Request | FormRequest | FormRequestPayload) => string + generate?: (request: AnyRequest) => string } 'forms-engine-plugin': { baseLayoutPath: string cacheService: CacheService viewContext?: ( - request: FormRequest | FormRequestPayload | null + request: AnyFormRequest | null ) => Record | Promise> saveAndExit?: PluginOptions['saveAndExit'] } From 762f4cd47cd44023e8dc6613f590b5dc50484b87 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 3 Sep 2025 13:41:57 +0100 Subject: [PATCH 12/12] Add docs on restoring a saved session --- docs/features/code-based/SAVE_AND_EXIT.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/features/code-based/SAVE_AND_EXIT.md b/docs/features/code-based/SAVE_AND_EXIT.md index 2f3a61ef5..d613308f1 100644 --- a/docs/features/code-based/SAVE_AND_EXIT.md +++ b/docs/features/code-based/SAVE_AND_EXIT.md @@ -65,8 +65,6 @@ The `FormSubmissionState` object can be found at `context.state` and contains al This is the data you'll need to save to allow users to pick up from where they left. -TODO: How to re-hydrate a session - ```typescript interface FormSubmissionState { // User's form field values @@ -79,3 +77,12 @@ interface FormSubmissionState { upload?: Record } ``` + +## Restore session data + +To restore a user's previous state use the `cacheService.setState` method. +The current request is passed in order to generate the cache key as so should include the correct form `slug` and `status` (if using the draft/live feature) + +```js +await cacheService.setState(request, state) +```