From 27a8157f770d8a0b99c9a6f20e1cbd837b0b1d7d Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 8 Sep 2025 16:55:03 +0100 Subject: [PATCH 1/7] feat: include form definition version in payload --- docs/FORM_DEFINITION_FORMATS.md | 15 +++++ package-lock.json | 8 +-- package.json | 2 +- .../outputFormatters/adapter/v1.test.ts | 64 +++++++++++++++++++ .../engine/outputFormatters/adapter/v1.ts | 5 +- .../engine/outputFormatters/machine/v1.ts | 5 +- .../engine/outputFormatters/machine/v2.ts | 17 +++-- src/server/plugins/engine/types.ts | 2 + .../plugins/engine/types/schema.test.ts | 40 ++++++++++++ src/server/plugins/engine/types/schema.ts | 11 +++- 10 files changed, 154 insertions(+), 15 deletions(-) diff --git a/docs/FORM_DEFINITION_FORMATS.md b/docs/FORM_DEFINITION_FORMATS.md index 9d130af35..78e2c1e63 100644 --- a/docs/FORM_DEFINITION_FORMATS.md +++ b/docs/FORM_DEFINITION_FORMATS.md @@ -52,4 +52,19 @@ pages: } ``` +## Version Metadata + +Form definitions can optionally include a `versionMetadata` field to track versioning information: + +```jsonc +{ + "name": "Form name", + "versionMetadata": { + "version": 19, + "createdAt": "2025-09-08T09:28:15.576Z" + }, + // ... rest of form definition +} +``` + See the [Custom Services guide](features/code-based/CUSTOM_SERVICES.md) for complete documentation on using the `FileFormService` class with the loader pattern, or for implementing custom `formsService` solutions for more complex requirements. diff --git a/package-lock.json b/package-lock.json index 5b6bffd00..fb4f615d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.506", + "@defra/forms-model": "^3.0.551", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2272,9 +2272,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.506", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.506.tgz", - "integrity": "sha512-Ac2ES6RdhZ44mmO9MQ40iZUVOdM0P8dqc2FfTQ8IqbFrbiTdUd5jcCAtn40HaU4tEg3v7qKFHlJC4XSUKm/eaA==", + "version": "3.0.551", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.551.tgz", + "integrity": "sha512-Mwqk4plEmgiBc7greLcJwmL/5Npr6fG0VV+Yo4KGlgy/KMiVvK3a20h7xj7dGN/E5tK4hdVJT7vPFT0CkEaH4g==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index c7ffb9a56..68e1dff96 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.506", + "@defra/forms-model": "^3.0.551", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index e4657c42d..ebfe7faab 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -693,4 +693,68 @@ describe('Adapter v1 formatter', () => { } }) }) + + it('should include versionMetadata when present in form definition', () => { + const modelWithVersion = new FormModel(definition, { + basePath: 'test' + }) + + modelWithVersion.def.versionMetadata = { + version: 19, + createdAt: '2025-09-08T09:28:15.576Z' + } + + const formMetadata: FormMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + modelWithVersion, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toEqual({ + version: 19, + createdAt: '2025-09-08T09:28:15.576Z' + }) + }) + + it('should handle missing versionMetadata gracefully', () => { + const formMetadata: FormMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toBeUndefined() + }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index a2fc5ab47..ee1b7d368 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -49,7 +49,10 @@ export function format( formSlug: formMetadata?.slug ?? '', status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, - notificationEmail: formMetadata?.notificationEmail ?? '' + notificationEmail: formMetadata?.notificationEmail ?? '', + ...(model.def.versionMetadata && { + versionMetadata: model.def.versionMetadata + }) } const data: FormAdapterSubmissionMessageData = transformedData diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index 73907567e..6a7138e1c 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -34,7 +34,10 @@ export function format( schemaVersion: '1', timestamp: now.toISOString(), referenceNumber: context.referenceNumber, - definition: model.def + definition: model.def, + ...(model.def.versionMetadata && { + versionMetadata: model.def.versionMetadata + }) }, data: categorisedData } diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index 215fb33d0..d82ddd567 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -29,13 +29,18 @@ export function format( const categorisedData = categoriseData(items) + const meta: Record = { + schemaVersion: '2', + timestamp: now.toISOString(), + definition: model.def, + referenceNumber: context.referenceNumber, + ...(model.def.versionMetadata && { + versionMetadata: model.def.versionMetadata + }) + } + const data = { - meta: { - schemaVersion: '2', - timestamp: now.toISOString(), - definition: model.def, - referenceNumber: context.referenceNumber - }, + meta, data: categorisedData } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 7576545d7..4594817e2 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -3,6 +3,7 @@ import { type Event, type FormDefinition, type FormMetadata, + type FormVersionMetadata, type Item, type List, type Page @@ -405,6 +406,7 @@ export interface FormAdapterSubmissionMessageMeta { status: FormStatus isPreview: boolean notificationEmail: string + versionMetadata?: FormVersionMetadata } export type FormAdapterSubmissionMessageMetaSerialised = Omit< diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index 4bbd324b1..7d78589e3 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -156,5 +156,45 @@ describe('Schema validation', () => { formAdapterSubmissionMessagePayloadSchema.validate(payloadWithoutData) expect(error).toBeDefined() }) + + it('should validate payload with versionMetadata', () => { + const payloadWithVersion = { + ...validPayload, + meta: { + ...validPayload.meta, + versionMetadata: { + version: 19, + createdAt: '2025-09-08T09:28:15.576Z' + } + } + } + const { error } = + formAdapterSubmissionMessagePayloadSchema.validate(payloadWithVersion) + expect(error).toBeUndefined() + }) + + it('should validate payload without versionMetadata', () => { + const { error } = + formAdapterSubmissionMessagePayloadSchema.validate(validPayload) + expect(error).toBeUndefined() + }) + + it('should reject invalid versionMetadata', () => { + const payloadWithInvalidVersion = { + ...validPayload, + meta: { + ...validPayload.meta, + versionMetadata: { + version: 'not-a-number', // Invalid - should be number + createdAt: '2025-09-08T09:28:15.576Z' + } + } + } + const { error } = formAdapterSubmissionMessagePayloadSchema.validate( + payloadWithInvalidVersion + ) + expect(error).toBeDefined() + expect(error?.message).toContain('must be a number') + }) }) }) diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index 57331bd1e..3362bf512 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -3,7 +3,8 @@ import { idSchema, notificationEmailAddressSchema, slugSchema, - titleSchema + titleSchema, + type FormVersionMetadata } from '@defra/forms-model' import Joi from 'joi' @@ -29,7 +30,13 @@ export const formAdapterSubmissionMessageMetaSchema = .valid(...Object.values(FormStatus)) .required(), isPreview: Joi.boolean().required(), - notificationEmail: notificationEmailAddressSchema.required() + notificationEmail: notificationEmailAddressSchema.required(), + versionMetadata: Joi.object() + .keys({ + version: Joi.number().required(), + createdAt: Joi.string().required() + }) + .optional() }) export const formAdapterSubmissionMessageDataSchema = From d14af6e80b2478fc660fd9a001def49c17e20503 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 9 Sep 2025 21:33:06 +0100 Subject: [PATCH 2/7] feat: send definition version in submission payload --- package-lock.json | 8 +- package.json | 2 +- .../plugins/engine/models/FormModel.test.ts | 64 ++++ src/server/plugins/engine/models/FormModel.ts | 7 +- .../outputFormatters/adapter/v1.test.ts | 299 +++++++++++++++--- .../engine/outputFormatters/adapter/v1.ts | 23 +- .../engine/outputFormatters/machine/v1.ts | 5 +- .../engine/outputFormatters/machine/v2.ts | 5 +- src/server/plugins/engine/routes/index.ts | 4 +- src/server/plugins/engine/types.ts | 1 + .../plugins/engine/types/schema.test.ts | 8 +- src/server/plugins/engine/types/schema.ts | 11 +- 12 files changed, 370 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb4f615d6..d9e29b1e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.551", + "@defra/forms-model": "^3.0.552", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2272,9 +2272,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.551", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.551.tgz", - "integrity": "sha512-Mwqk4plEmgiBc7greLcJwmL/5Npr6fG0VV+Yo4KGlgy/KMiVvK3a20h7xj7dGN/E5tK4hdVJT7vPFT0CkEaH4g==", + "version": "3.0.552", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.552.tgz", + "integrity": "sha512-g2ZpUHL+CQyJbGX1kBpM7pXy33GVKMaS/95it0tJXkkNECO+0pkBdvu2EVrUD6GMHAirvWzhvFVA0y1CX8rP3g==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index 68e1dff96..caa3b3131 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.551", + "@defra/forms-model": "^3.0.552", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 8154c3c77..cd77cfcec 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -141,6 +141,21 @@ describe('FormModel', () => { expect(model.schemaVersion).toBe(SchemaVersion.V1) }) + it('sets versionNumber from options', () => { + const model = new FormModel(definition, { + basePath: 'test', + versionNumber: 42 + }) + + expect(model.versionNumber).toBe(42) + }) + + it('sets versionNumber to undefined when not provided', () => { + const model = new FormModel(definition, { basePath: 'test' }) + + expect(model.versionNumber).toBeUndefined() + }) + it.each([ { input: undefined, @@ -329,6 +344,55 @@ describe('FormModel', () => { ) }) + it('includes submittedVersionNumber in context when versionNumber is set', () => { + const formModel = new FormModel(fieldsRequiredDefinition, { + basePath: '/components', + versionNumber: 123 + }) + + const state = { + $$__referenceNumber: 'foobar' + } + const pageUrl = new URL('http://example.com/components/fields-required') + + const request: FormContextRequest = buildFormContextRequest({ + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'components', slug: 'fields-required' }, + url: pageUrl, + app: { model: formModel } + }) + + const context = formModel.getFormContext(request, state) + + expect(context.submittedVersionNumber).toBe(123) + }) + + it('sets submittedVersionNumber to undefined when versionNumber is not set', () => { + const formModel = new FormModel(fieldsRequiredDefinition, { + basePath: '/components' + }) + + const state = { + $$__referenceNumber: 'foobar' + } + const pageUrl = new URL('http://example.com/components/fields-required') + + const request: FormContextRequest = buildFormContextRequest({ + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'components', slug: 'fields-required' }, + url: pageUrl, + app: { model: formModel } + }) + + const context = formModel.getFormContext(request, state) + + expect(context.submittedVersionNumber).toBeUndefined() + }) + it('redirects to the page if the list field (radio) is invalidated due to list item conditions', () => { const formModel = new FormModel(conditionsListDefinition, { basePath: '/conditional-list-items' diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 21c838d91..4c9277400 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -76,6 +76,7 @@ export class FormModel { name: string values: FormDefinition basePath: string + versionNumber?: number conditions: Partial> pages: PageControllerClass[] services: Services @@ -94,7 +95,7 @@ export class FormModel { constructor( def: typeof this.def, - options: { basePath: string }, + options: { basePath: string; versionNumber?: number }, services: Services = defaultServices, controllers?: Record ) { @@ -148,6 +149,7 @@ export class FormModel { this.name = def.name ?? '' this.values = result.value this.basePath = options.basePath + this.versionNumber = options.versionNumber this.conditions = {} this.services = services this.controllers = controllers @@ -344,7 +346,8 @@ export class FormModel { componentDefMap: this.componentDefMap, pageMap: this.pageMap, componentMap: this.componentMap, - referenceNumber: getReferenceNumber(state) + referenceNumber: getReferenceNumber(state), + submittedVersionNumber: this.versionNumber } // Validate current page diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index ebfe7faab..3b958a19c 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -694,16 +694,7 @@ describe('Adapter v1 formatter', () => { }) }) - it('should include versionMetadata when present in form definition', () => { - const modelWithVersion = new FormModel(definition, { - basePath: 'test' - }) - - modelWithVersion.def.versionMetadata = { - version: 19, - createdAt: '2025-09-08T09:28:15.576Z' - } - + it('should handle missing versionMetadata gracefully', () => { const formMetadata: FormMetadata = { id: 'form-123', slug: 'test-form', @@ -719,42 +710,278 @@ describe('Adapter v1 formatter', () => { const body = format( context, items, - modelWithVersion, + model, submitResponse, formStatus, formMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload - expect(parsedBody.meta.versionMetadata).toEqual({ - version: 19, - createdAt: '2025-09-08T09:28:15.576Z' - }) + expect(parsedBody.meta.versionMetadata).toBeUndefined() }) - it('should handle missing versionMetadata gracefully', () => { - const formMetadata: FormMetadata = { - id: 'form-123', - slug: 'test-form', - title: 'Test Form', - notificationEmail: 'test@example.com' - } as FormMetadata + describe('version metadata handling', () => { + it('should include versionMetadata when context has submittedVersionNumber and formMetadata has versions', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 2, + createdAt: new Date('2024-01-15T00:00:00.000Z') + } + ] + } as unknown as FormMetadata - const formStatus = { - isPreview: false, - state: FormStatus.Live - } + const modelWithVersion = new FormModel(definition, { + basePath: 'test', + versionNumber: 2 + }) - const body = format( - context, - items, - model, - submitResponse, - formStatus, - formMetadata - ) - const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + const contextWithVersion = modelWithVersion.getFormContext(request, state) - expect(parsedBody.meta.versionMetadata).toBeUndefined() + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + contextWithVersion, + items, + modelWithVersion, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toEqual({ + versionNumber: 2, + createdAt: new Date('2024-01-15T00:00:00.000Z') + }) + }) + + it('should use first version as fallback when submittedVersionNumber is undefined', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 2, + createdAt: new Date('2024-01-15T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toEqual({ + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + + it('should not include versionMetadata when submittedVersionNumber is undefined and no versions exist', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com' + } as unknown as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toBeUndefined() + }) + + it('should not include versionMetadata when submittedVersionNumber is undefined and versions array is empty', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [] + } as unknown as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toBeUndefined() + }) + + it('should not include versionMetadata when submittedVersionNumber does not match any version', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 2, + createdAt: new Date('2024-01-15T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const modelWithVersion = new FormModel(definition, { + basePath: 'test', + versionNumber: 99 // Non-existent version + }) + + const contextWithVersion = modelWithVersion.getFormContext(request, state) + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + contextWithVersion, + items, + modelWithVersion, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toBeUndefined() + }) + + it('should use first version as fallback when submittedVersionNumber does not match any version', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 2, + createdAt: new Date('2024-01-15T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const modelWithVersion = new FormModel(definition, { + basePath: 'test', + versionNumber: 99 // Non-existent version + }) + + const contextWithVersion = modelWithVersion.getFormContext(request, state) + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + contextWithVersion, + items, + modelWithVersion, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + // Should fall back to first version since submittedVersionNumber doesn't match + expect(parsedBody.meta.versionMetadata).toEqual({ + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + + it('should handle single version in versions array', () => { + const formMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 5, + createdAt: new Date('2024-02-01T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.versionMetadata).toEqual({ + versionNumber: 5, + createdAt: new Date('2024-02-01T00:00:00.000Z') + }) + }) }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index ee1b7d368..296631a4c 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -40,6 +40,25 @@ export function format( const transformedData = v2DataParsed.data + let versionMetadata: { versionNumber: number; createdAt: Date } | undefined + + if (context.submittedVersionNumber !== undefined && formMetadata?.versions) { + const submittedVersion = formMetadata.versions.find( + (v) => v.versionNumber === context.submittedVersionNumber + ) + if (submittedVersion) { + versionMetadata = { + versionNumber: submittedVersion.versionNumber, + createdAt: submittedVersion.createdAt + } + } + } else if (formMetadata?.versions && formMetadata.versions.length > 0) { + versionMetadata = { + versionNumber: formMetadata.versions[0].versionNumber, + createdAt: formMetadata.versions[0].createdAt + } + } + const meta: FormAdapterSubmissionMessageMeta = { schemaVersion: FormAdapterSubmissionSchemaVersion.V1, timestamp: new Date(), @@ -50,9 +69,7 @@ export function format( status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, notificationEmail: formMetadata?.notificationEmail ?? '', - ...(model.def.versionMetadata && { - versionMetadata: model.def.versionMetadata - }) + ...(versionMetadata && { versionMetadata }) } const data: FormAdapterSubmissionMessageData = transformedData diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index 6a7138e1c..73907567e 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -34,10 +34,7 @@ export function format( schemaVersion: '1', timestamp: now.toISOString(), referenceNumber: context.referenceNumber, - definition: model.def, - ...(model.def.versionMetadata && { - versionMetadata: model.def.versionMetadata - }) + definition: model.def }, data: categorisedData } diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index d82ddd567..a62a51ed3 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -33,10 +33,7 @@ export function format( schemaVersion: '2', timestamp: now.toISOString(), definition: model.def, - referenceNumber: context.referenceNumber, - ...(model.def.versionMetadata && { - versionMetadata: model.def.versionMetadata - }) + referenceNumber: context.referenceNumber } const data = { diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index c34b2ad53..93ea2584c 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -151,10 +151,12 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { : `${prefix}/${slug}` ).substring(1) + const versionNumber = metadata.versions?.[0]?.versionNumber + // Construct the form model const model = new FormModel( definition, - { basePath }, + { basePath, versionNumber }, services, controllers ) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 4594817e2..a505252f6 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -180,6 +180,7 @@ export interface FormContext { pageMap: Map componentMap: Map referenceNumber: string + submittedVersionNumber?: number } export type FormContextRequest = ( diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index 7d78589e3..b07a48392 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -163,8 +163,8 @@ describe('Schema validation', () => { meta: { ...validPayload.meta, versionMetadata: { - version: 19, - createdAt: '2025-09-08T09:28:15.576Z' + versionNumber: 19, + createdAt: new Date('2025-09-08T09:28:15.576Z') } } } @@ -185,8 +185,8 @@ describe('Schema validation', () => { meta: { ...validPayload.meta, versionMetadata: { - version: 'not-a-number', // Invalid - should be number - createdAt: '2025-09-08T09:28:15.576Z' + versionNumber: 'not-a-number', // Invalid - should be number + createdAt: new Date('2025-09-08T09:28:15.576Z') } } } diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index 3362bf512..396d965cb 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -1,10 +1,10 @@ import { FormStatus, + formVersionMetadataSchema, idSchema, notificationEmailAddressSchema, slugSchema, - titleSchema, - type FormVersionMetadata + titleSchema } from '@defra/forms-model' import Joi from 'joi' @@ -31,12 +31,7 @@ export const formAdapterSubmissionMessageMetaSchema = .required(), isPreview: Joi.boolean().required(), notificationEmail: notificationEmailAddressSchema.required(), - versionMetadata: Joi.object() - .keys({ - version: Joi.number().required(), - createdAt: Joi.string().required() - }) - .optional() + versionMetadata: formVersionMetadataSchema.optional() }) export const formAdapterSubmissionMessageDataSchema = From 70cd3790ddddc2bafedced1eac3544bfac862f3f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 9 Sep 2025 21:47:39 +0100 Subject: [PATCH 3/7] test: fix failing tests --- .../engine/outputFormatters/adapter/v1.test.ts | 14 +++++++++----- .../plugins/engine/outputFormatters/adapter/v1.ts | 5 +++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 3b958a19c..3c0810d14 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -763,7 +763,7 @@ describe('Adapter v1 formatter', () => { expect(parsedBody.meta.versionMetadata).toEqual({ versionNumber: 2, - createdAt: new Date('2024-01-15T00:00:00.000Z') + createdAt: '2024-01-15T00:00:00.000Z' }) }) @@ -802,7 +802,7 @@ describe('Adapter v1 formatter', () => { expect(parsedBody.meta.versionMetadata).toEqual({ versionNumber: 1, - createdAt: new Date('2024-01-01T00:00:00.000Z') + createdAt: '2024-01-01T00:00:00.000Z' }) }) @@ -899,7 +899,11 @@ describe('Adapter v1 formatter', () => { ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload - expect(parsedBody.meta.versionMetadata).toBeUndefined() + // Should fall back to first version since submittedVersionNumber doesn't match + expect(parsedBody.meta.versionMetadata).toEqual({ + versionNumber: 1, + createdAt: '2024-01-01T00:00:00.000Z' + }) }) it('should use first version as fallback when submittedVersionNumber does not match any version', () => { @@ -945,7 +949,7 @@ describe('Adapter v1 formatter', () => { // Should fall back to first version since submittedVersionNumber doesn't match expect(parsedBody.meta.versionMetadata).toEqual({ versionNumber: 1, - createdAt: new Date('2024-01-01T00:00:00.000Z') + createdAt: '2024-01-01T00:00:00.000Z' }) }) @@ -980,7 +984,7 @@ describe('Adapter v1 formatter', () => { expect(parsedBody.meta.versionMetadata).toEqual({ versionNumber: 5, - createdAt: new Date('2024-02-01T00:00:00.000Z') + createdAt: '2024-02-01T00:00:00.000Z' }) }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index 296631a4c..50d6907b7 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -51,6 +51,11 @@ export function format( versionNumber: submittedVersion.versionNumber, createdAt: submittedVersion.createdAt } + } else if (formMetadata.versions.length > 0) { + versionMetadata = { + versionNumber: formMetadata.versions[0].versionNumber, + createdAt: formMetadata.versions[0].createdAt + } } } else if (formMetadata?.versions && formMetadata.versions.length > 0) { versionMetadata = { From 25e9408b4cc2e2ea2d5543a91ddef7f5632884ec Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 9 Sep 2025 21:57:32 +0100 Subject: [PATCH 4/7] refasctor: split into helper and test --- .../outputFormatters/adapter/v1.test.ts | 125 +++++++++++++++++- .../engine/outputFormatters/adapter/v1.ts | 53 ++++---- 2 files changed, 154 insertions(+), 24 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 3c0810d14..a363130ee 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -11,7 +11,10 @@ import { type DetailItemField, type DetailItemRepeat } from '~/src/server/plugins/engine/models/types.js' -import { format } from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js' +import { + format, + getVersionMetadata +} from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/index.js' import { @@ -988,4 +991,124 @@ describe('Adapter v1 formatter', () => { }) }) }) + + describe('getVersionMetadata', () => { + const mockFormMetadata: FormMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com', + versions: [ + { + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 2, + createdAt: new Date('2024-01-02T00:00:00.000Z') + }, + { + versionNumber: 3, + createdAt: new Date('2024-01-03T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + it('should return undefined when no form metadata provided', () => { + const result = getVersionMetadata(1, undefined) + expect(result).toBeUndefined() + }) + + it('should return undefined when form metadata has no versions', () => { + const formMetadataWithoutVersions = { + ...mockFormMetadata, + versions: undefined + } as unknown as FormMetadata + + const result = getVersionMetadata(1, formMetadataWithoutVersions) + expect(result).toBeUndefined() + }) + + it('should return undefined when versions array is empty', () => { + const formMetadataWithEmptyVersions = { + ...mockFormMetadata, + versions: [] + } as unknown as FormMetadata + + const result = getVersionMetadata(1, formMetadataWithEmptyVersions) + expect(result).toBeUndefined() + }) + + it('should return specific version when submittedVersionNumber matches', () => { + const result = getVersionMetadata(2, mockFormMetadata) + expect(result).toEqual({ + versionNumber: 2, + createdAt: new Date('2024-01-02T00:00:00.000Z') + }) + }) + + it('should return first version when submittedVersionNumber not found', () => { + const result = getVersionMetadata(999, mockFormMetadata) + expect(result).toEqual({ + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + + it('should return first version when no submittedVersionNumber provided', () => { + const result = getVersionMetadata(undefined, mockFormMetadata) + expect(result).toEqual({ + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + + it('should handle single version in versions array', () => { + const singleVersionMetadata = { + ...mockFormMetadata, + versions: [ + { + versionNumber: 5, + createdAt: new Date('2024-02-01T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const result = getVersionMetadata(undefined, singleVersionMetadata) + expect(result).toEqual({ + versionNumber: 5, + createdAt: new Date('2024-02-01T00:00:00.000Z') + }) + }) + + it('should return correct version when submittedVersionNumber is 0', () => { + const metadataWithVersionZero = { + ...mockFormMetadata, + versions: [ + { + versionNumber: 0, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }, + { + versionNumber: 1, + createdAt: new Date('2024-01-02T00:00:00.000Z') + } + ] + } as unknown as FormMetadata + + const result = getVersionMetadata(0, metadataWithVersionZero) + expect(result).toEqual({ + versionNumber: 0, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + + it('should handle negative submittedVersionNumber by falling back to first version', () => { + const result = getVersionMetadata(-1, mockFormMetadata) + expect(result).toEqual({ + versionNumber: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z') + }) + }) + }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index 50d6907b7..b8331ca14 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -40,29 +40,10 @@ export function format( const transformedData = v2DataParsed.data - let versionMetadata: { versionNumber: number; createdAt: Date } | undefined - - if (context.submittedVersionNumber !== undefined && formMetadata?.versions) { - const submittedVersion = formMetadata.versions.find( - (v) => v.versionNumber === context.submittedVersionNumber - ) - if (submittedVersion) { - versionMetadata = { - versionNumber: submittedVersion.versionNumber, - createdAt: submittedVersion.createdAt - } - } else if (formMetadata.versions.length > 0) { - versionMetadata = { - versionNumber: formMetadata.versions[0].versionNumber, - createdAt: formMetadata.versions[0].createdAt - } - } - } else if (formMetadata?.versions && formMetadata.versions.length > 0) { - versionMetadata = { - versionNumber: formMetadata.versions[0].versionNumber, - createdAt: formMetadata.versions[0].createdAt - } - } + const versionMetadata = getVersionMetadata( + context.submittedVersionNumber, + formMetadata + ) const meta: FormAdapterSubmissionMessageMeta = { schemaVersion: FormAdapterSubmissionSchemaVersion.V1, @@ -91,6 +72,32 @@ export function format( return JSON.stringify(payload) } +export function getVersionMetadata( + submittedVersionNumber: number | undefined, + formMetadata?: FormMetadata +): { versionNumber: number; createdAt: Date } | undefined { + if (!formMetadata?.versions?.length) return undefined + + if (submittedVersionNumber !== undefined) { + const submittedVersion = formMetadata.versions.find( + (v) => v.versionNumber === submittedVersionNumber + ) + if (submittedVersion) { + return { + versionNumber: submittedVersion.versionNumber, + createdAt: submittedVersion.createdAt + } + } + } + + // fallback to first available version + const firstVersion = formMetadata.versions[0] + return { + versionNumber: firstVersion.versionNumber, + createdAt: firstVersion.createdAt + } +} + function extractCsvFiles( submitResponse: SubmitResponsePayload ): FormAdapterSubmissionMessageResult['files'] { From 15957223be969e4f4dba316475781893ec14bea8 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 9 Sep 2025 22:00:07 +0100 Subject: [PATCH 5/7] chore: update readme --- docs/FORM_DEFINITION_FORMATS.md | 30 ++++-- .../outputFormatters/adapter/v1.test.ts | 95 ++++++++++--------- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/docs/FORM_DEFINITION_FORMATS.md b/docs/FORM_DEFINITION_FORMATS.md index 78e2c1e63..4e61b0ec3 100644 --- a/docs/FORM_DEFINITION_FORMATS.md +++ b/docs/FORM_DEFINITION_FORMATS.md @@ -54,17 +54,35 @@ pages: ## Version Metadata -Form definitions can optionally include a `versionMetadata` field to track versioning information: +Version information is automatically included in the formatted submission data when available. The `versionMetadata` field appears in the `meta` section of the submission payload, not in the form definition itself. + +When form metadata includes version information, the submission payload will include: ```jsonc { - "name": "Form name", - "versionMetadata": { - "version": 19, - "createdAt": "2025-09-08T09:28:15.576Z" + "meta": { + "schemaVersion": "v1", + "timestamp": "2025-09-08T09:28:15.576Z", + "referenceNumber": "REF-123456", + "formName": "Form name", + "formId": "form-123", + "formSlug": "form-slug", + "status": "Live", + "isPreview": false, + "notificationEmail": "admin@example.com", + "versionMetadata": { + "versionNumber": 19, + "createdAt": "2025-09-08T09:28:15.576Z" + } }, - // ... rest of form definition + "data": { /* form submission data */ }, + "result": { /* file attachments */ } } ``` +The version metadata is determined by: + +1. The specific version number submitted (if `submittedVersionNumber` is provided and matches a version) +2. The first available version (as a fallback) + See the [Custom Services guide](features/code-based/CUSTOM_SERVICES.md) for complete documentation on using the `FileFormService` class with the loader pattern, or for implementing custom `formsService` solutions for more complex requirements. diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index a363130ee..ff45f733c 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -210,7 +210,7 @@ describe('Adapter v1 formatter', () => { }) it('should return the adapter v1 output with complete formMetadata', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -228,7 +228,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -287,7 +287,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle preview form status correctly', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -305,7 +305,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -330,7 +330,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle partial formMetadata', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { id: 'form-456', slug: 'partial-form', title: 'Partial Form' @@ -347,7 +347,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -455,7 +455,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle formMetadata with only id', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { id: 'only-id-form' } as FormMetadata @@ -470,7 +470,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -480,7 +480,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle formMetadata with only slug', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { slug: 'only-slug-form' } as FormMetadata @@ -495,7 +495,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -505,7 +505,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle formMetadata with only notificationEmail', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { notificationEmail: 'only-email@example.com' } as FormMetadata @@ -520,7 +520,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -698,7 +698,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle missing versionMetadata gracefully', () => { - const formMetadata: FormMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -716,7 +716,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -725,7 +725,7 @@ describe('Adapter v1 formatter', () => { describe('version metadata handling', () => { it('should include versionMetadata when context has submittedVersionNumber and formMetadata has versions', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -740,7 +740,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-15T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const modelWithVersion = new FormModel(definition, { basePath: 'test', @@ -760,7 +760,7 @@ describe('Adapter v1 formatter', () => { modelWithVersion, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -771,7 +771,7 @@ describe('Adapter v1 formatter', () => { }) it('should use first version as fallback when submittedVersionNumber is undefined', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -786,7 +786,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-15T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const formStatus = { isPreview: false, @@ -799,7 +799,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -810,12 +810,12 @@ describe('Adapter v1 formatter', () => { }) it('should not include versionMetadata when submittedVersionNumber is undefined and no versions exist', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', notificationEmail: 'test@example.com' - } as unknown as FormMetadata + } const formStatus = { isPreview: false, @@ -828,7 +828,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -836,13 +836,13 @@ describe('Adapter v1 formatter', () => { }) it('should not include versionMetadata when submittedVersionNumber is undefined and versions array is empty', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', notificationEmail: 'test@example.com', versions: [] - } as unknown as FormMetadata + } const formStatus = { isPreview: false, @@ -855,7 +855,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -863,7 +863,7 @@ describe('Adapter v1 formatter', () => { }) it('should not include versionMetadata when submittedVersionNumber does not match any version', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -878,7 +878,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-15T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const modelWithVersion = new FormModel(definition, { basePath: 'test', @@ -898,7 +898,7 @@ describe('Adapter v1 formatter', () => { modelWithVersion, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -910,7 +910,7 @@ describe('Adapter v1 formatter', () => { }) it('should use first version as fallback when submittedVersionNumber does not match any version', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -925,7 +925,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-15T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const modelWithVersion = new FormModel(definition, { basePath: 'test', @@ -945,7 +945,7 @@ describe('Adapter v1 formatter', () => { modelWithVersion, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -957,7 +957,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle single version in versions array', () => { - const formMetadata = { + const formMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -968,7 +968,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-02-01T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const formStatus = { isPreview: false, @@ -981,7 +981,7 @@ describe('Adapter v1 formatter', () => { model, submitResponse, formStatus, - formMetadata + formMetadata as FormMetadata ) const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload @@ -993,7 +993,7 @@ describe('Adapter v1 formatter', () => { }) describe('getVersionMetadata', () => { - const mockFormMetadata: FormMetadata = { + const mockFormMetadata: Partial = { id: 'form-123', slug: 'test-form', title: 'Test Form', @@ -1012,7 +1012,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-03T00:00:00.000Z') } ] - } as unknown as FormMetadata + } it('should return undefined when no form metadata provided', () => { const result = getVersionMetadata(1, undefined) @@ -1020,20 +1020,23 @@ describe('Adapter v1 formatter', () => { }) it('should return undefined when form metadata has no versions', () => { - const formMetadataWithoutVersions = { + const formMetadataWithoutVersions: Partial = { ...mockFormMetadata, versions: undefined - } as unknown as FormMetadata + } - const result = getVersionMetadata(1, formMetadataWithoutVersions) + const result = getVersionMetadata( + 1, + formMetadataWithoutVersions as FormMetadata + ) expect(result).toBeUndefined() }) it('should return undefined when versions array is empty', () => { - const formMetadataWithEmptyVersions = { + const formMetadataWithEmptyVersions: Partial = { ...mockFormMetadata, versions: [] - } as unknown as FormMetadata + } const result = getVersionMetadata(1, formMetadataWithEmptyVersions) expect(result).toBeUndefined() @@ -1064,7 +1067,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle single version in versions array', () => { - const singleVersionMetadata = { + const singleVersionMetadata: Partial = { ...mockFormMetadata, versions: [ { @@ -1072,7 +1075,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-02-01T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const result = getVersionMetadata(undefined, singleVersionMetadata) expect(result).toEqual({ @@ -1082,7 +1085,7 @@ describe('Adapter v1 formatter', () => { }) it('should return correct version when submittedVersionNumber is 0', () => { - const metadataWithVersionZero = { + const metadataWithVersionZero: Partial = { ...mockFormMetadata, versions: [ { @@ -1094,7 +1097,7 @@ describe('Adapter v1 formatter', () => { createdAt: new Date('2024-01-02T00:00:00.000Z') } ] - } as unknown as FormMetadata + } const result = getVersionMetadata(0, metadataWithVersionZero) expect(result).toEqual({ From 1b4d2f8cb2f2374f27126f27912d6f8047d1a391 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 10 Sep 2025 10:51:38 +0100 Subject: [PATCH 6/7] chore: PR comments and lint fix --- docs/FORM_DEFINITION_FORMATS.md | 33 ------------------- .../outputFormatters/adapter/v1.test.ts | 26 +++++++++++---- .../engine/outputFormatters/adapter/v1.ts | 7 ++-- 3 files changed, 24 insertions(+), 42 deletions(-) diff --git a/docs/FORM_DEFINITION_FORMATS.md b/docs/FORM_DEFINITION_FORMATS.md index 4e61b0ec3..9d130af35 100644 --- a/docs/FORM_DEFINITION_FORMATS.md +++ b/docs/FORM_DEFINITION_FORMATS.md @@ -52,37 +52,4 @@ pages: } ``` -## Version Metadata - -Version information is automatically included in the formatted submission data when available. The `versionMetadata` field appears in the `meta` section of the submission payload, not in the form definition itself. - -When form metadata includes version information, the submission payload will include: - -```jsonc -{ - "meta": { - "schemaVersion": "v1", - "timestamp": "2025-09-08T09:28:15.576Z", - "referenceNumber": "REF-123456", - "formName": "Form name", - "formId": "form-123", - "formSlug": "form-slug", - "status": "Live", - "isPreview": false, - "notificationEmail": "admin@example.com", - "versionMetadata": { - "versionNumber": 19, - "createdAt": "2025-09-08T09:28:15.576Z" - } - }, - "data": { /* form submission data */ }, - "result": { /* file attachments */ } -} -``` - -The version metadata is determined by: - -1. The specific version number submitted (if `submittedVersionNumber` is provided and matches a version) -2. The first available version (as a fallback) - See the [Custom Services guide](features/code-based/CUSTOM_SERVICES.md) for complete documentation on using the `FileFormService` class with the loader pattern, or for implementing custom `formsService` solutions for more complex requirements. diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index ff45f733c..7c3f115cd 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -1038,12 +1038,15 @@ describe('Adapter v1 formatter', () => { versions: [] } - const result = getVersionMetadata(1, formMetadataWithEmptyVersions) + const result = getVersionMetadata( + 1, + formMetadataWithEmptyVersions as FormMetadata + ) expect(result).toBeUndefined() }) it('should return specific version when submittedVersionNumber matches', () => { - const result = getVersionMetadata(2, mockFormMetadata) + const result = getVersionMetadata(2, mockFormMetadata as FormMetadata) expect(result).toEqual({ versionNumber: 2, createdAt: new Date('2024-01-02T00:00:00.000Z') @@ -1051,7 +1054,7 @@ describe('Adapter v1 formatter', () => { }) it('should return first version when submittedVersionNumber not found', () => { - const result = getVersionMetadata(999, mockFormMetadata) + const result = getVersionMetadata(999, mockFormMetadata as FormMetadata) expect(result).toEqual({ versionNumber: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') @@ -1059,7 +1062,10 @@ describe('Adapter v1 formatter', () => { }) it('should return first version when no submittedVersionNumber provided', () => { - const result = getVersionMetadata(undefined, mockFormMetadata) + const result = getVersionMetadata( + undefined, + mockFormMetadata as FormMetadata + ) expect(result).toEqual({ versionNumber: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') @@ -1077,7 +1083,10 @@ describe('Adapter v1 formatter', () => { ] } - const result = getVersionMetadata(undefined, singleVersionMetadata) + const result = getVersionMetadata( + undefined, + singleVersionMetadata as FormMetadata + ) expect(result).toEqual({ versionNumber: 5, createdAt: new Date('2024-02-01T00:00:00.000Z') @@ -1099,7 +1108,10 @@ describe('Adapter v1 formatter', () => { ] } - const result = getVersionMetadata(0, metadataWithVersionZero) + const result = getVersionMetadata( + 0, + metadataWithVersionZero as FormMetadata + ) expect(result).toEqual({ versionNumber: 0, createdAt: new Date('2024-01-01T00:00:00.000Z') @@ -1107,7 +1119,7 @@ describe('Adapter v1 formatter', () => { }) it('should handle negative submittedVersionNumber by falling back to first version', () => { - const result = getVersionMetadata(-1, mockFormMetadata) + const result = getVersionMetadata(-1, mockFormMetadata as FormMetadata) expect(result).toEqual({ versionNumber: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index b8331ca14..d43af68de 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -54,8 +54,11 @@ export function format( formSlug: formMetadata?.slug ?? '', status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, - notificationEmail: formMetadata?.notificationEmail ?? '', - ...(versionMetadata && { versionMetadata }) + notificationEmail: formMetadata?.notificationEmail ?? '' + } + + if (versionMetadata) { + meta.versionMetadata = versionMetadata } const data: FormAdapterSubmissionMessageData = transformedData From ab89853f786664925c19ef769d28c3d38a113a62 Mon Sep 17 00:00:00 2001 From: whitewater design Date: Wed, 10 Sep 2025 12:39:16 +0100 Subject: [PATCH 7/7] refactor: DF-468 - Remove single line if statement --- src/server/plugins/engine/outputFormatters/adapter/v1.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index d43af68de..4f528f88f 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -79,7 +79,9 @@ export function getVersionMetadata( submittedVersionNumber: number | undefined, formMetadata?: FormMetadata ): { versionNumber: number; createdAt: Date } | undefined { - if (!formMetadata?.versions?.length) return undefined + if (!formMetadata?.versions?.length) { + return undefined + } if (submittedVersionNumber !== undefined) { const submittedVersion = formMetadata.versions.find(