diff --git a/docs/features/configuration-based/page-events.md b/docs/features/configuration-based/page-events.md index 25dd544b7..322ff9abb 100644 --- a/docs/features/configuration-based/page-events.md +++ b/docs/features/configuration-based/page-events.md @@ -58,7 +58,7 @@ The payload takes the following shape: ```jsonc { "meta": { - "schemaVersion": "2", + "schemaVersion": 1, "timestamp": "2025-03-25T10:00:00Z", "definition": { // This object would be a full copy of the form definition at the time of submission. It is excluded for brevity. diff --git a/package-lock.json b/package-lock.json index fdc458361..2ff8e96c4 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.698", + "@defra/forms-model": "^3.0.704", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "0.0.33-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -3697,9 +3697,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.698", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.698.tgz", - "integrity": "sha512-HTW7NQrbX7kPbN7Msawyy414GZuiiqRKdOfLmos3W9myY6fTFtBgw8E0r7goHxwuCBePxFZdNgg3KK3t10WFGA==", + "version": "3.0.704", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.704.tgz", + "integrity": "sha512-+yxRe8TxWMoBwlZl9FSobXWEJoqgTip1uq5GBoBylXUxG/r33oB+Jk2tdX5VqpEEZN7wYkfzOFqACd7Wf2kzfQ==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index a83fc1f0e..74048435d 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.698", + "@defra/forms-model": "^3.0.704", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "0.0.33-alpha", "@elastic/ecs-pino-format": "^1.5.0", diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 0309e8826..8ebd877c2 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -1,5 +1,6 @@ import { ComponentType, + ConditionEvaluationOutcome, ConditionsModel, ControllerPath, ControllerType, @@ -8,6 +9,7 @@ import { formDefinitionSchema, formDefinitionV2Schema, generateConditionAlias, + getErrorMessage, hasComponents, hasComponentsEvenIfNoNext, hasRepeater, @@ -54,7 +56,10 @@ import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extrac import { createFormI18nInstance } from '~/src/server/plugins/engine/i18n/index.js' import { getAvailableLanguages } from '~/src/server/plugins/engine/i18n/languages.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' -import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' +import { + type ConditionEvaluation, + type ExecutableCondition +} from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { createPage, @@ -304,21 +309,35 @@ export class FormModel { throw new ConditionBuildError(displayName, { cause }) } - const fn = (evaluationState: FormState) => { + const evaluate = (evaluationState: FormState): ConditionEvaluation => { const ctx = this.toConditionContext(evaluationState, this.conditions) + try { - return expr.evaluate(ctx) as boolean - } catch { - return false + return { + outcome: (expr.evaluate(ctx) as boolean) + ? ConditionEvaluationOutcome.True + : ConditionEvaluationOutcome.False + } + } catch (err) { + return { + outcome: ConditionEvaluationOutcome.Error, + error: getErrorMessage(err) + } } } + // A failed evaluation continues to route as `false`. `evaluate` exists so + // that the two can be told apart when recording outcomes for submission. + const fn = (evaluationState: FormState) => + evaluate(evaluationState).outcome === ConditionEvaluationOutcome.True + return { name, displayName, value, expr, - fn + fn, + evaluate } } diff --git a/src/server/plugins/engine/models/types.ts b/src/server/plugins/engine/models/types.ts index 980ad8f0a..db177d605 100644 --- a/src/server/plugins/engine/models/types.ts +++ b/src/server/plugins/engine/models/types.ts @@ -1,4 +1,5 @@ import { + type ConditionEvaluationOutcome, type ConditionWrapper, type FormComponentsDef, type Section @@ -16,9 +17,39 @@ import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' +/** + * The result of evaluating a condition, keeping a failed evaluation distinct + * from one that legitimately returned `false`. + * @see {@link ExecutableCondition.evaluate} + */ +export interface ConditionEvaluation { + outcome: ConditionEvaluationOutcome + error?: string +} + +/** + * A form condition paired with the parsed expression and callbacks needed to + * run it against a form submission state + * Created by `FormModel.makeCondition` + */ export type ExecutableCondition = ConditionWrapper & { + /** + * Parsed expression for the condition's {@link ConditionWrapper.value}, + * evaluated against a context built from the submission state + */ expr: Expression + + /** + * Evaluates the condition, used for page routing and component visibility + * A failed evaluation is reported as `false` + */ fn: (evaluationState: FormState) => boolean + + /** + * As `fn`, but reports whether evaluation failed rather than defaulting a + * failure to `false`. Used to record condition outcomes on submission. + */ + evaluate: (evaluationState: FormState) => ConditionEvaluation } /** diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 7bd103e25..b3f4d00d4 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -15,13 +15,16 @@ import { import { format } 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 { formAdapterSubmissionMessagePayloadSchema } from '~/src/server/plugins/engine/types/schema.js' import { FileStatus, UploadStatus, type FileState, - type FormAdapterSubmissionMessagePayload + type FormAdapterSubmissionMessagePayload, + type FormContext } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' +import joinedConditionsDefinition from '~/test/form/definitions/joined-conditions-simple-v2.js' import definition from '~/test/form/definitions/repeat-mixed.js' const submitResponse = { @@ -871,3 +874,88 @@ describe('Adapter v1 formatter', () => { }) }) }) + +describe('conditionEvaluations', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const formMetadata = { + id: '68a8b0449ab460290c28940a', + slug: 'order-a-pizza', + notificationEmail: 'submissions@example.com' + } as FormMetadata + + const v2Model = new FormModel(joinedConditionsDefinition, { + basePath: 'test' + }) + + // The formatter only reads the reference number, translator and evaluation + // state from the context, so the full page-walk state is not needed here + const v2Context = { + referenceNumber: 'foobar', + evaluationState: { userName: 'Bob', isOverEighteen: true } + } as unknown as FormContext + + const formatV2Definition = () => + JSON.parse( + format( + v2Context, + items, + v2Model, + submitResponse, + formStatus, + formMetadata + ) + ) as FormAdapterSubmissionMessagePayload + + const formatV1Definition = () => + JSON.parse( + format( + context, + items, + model, + submitResponse as SubmitResponsePayload, + formStatus, + formMetadata + ) + ) as FormAdapterSubmissionMessagePayload + + it('should record the outcome of every condition for a V2 definition', () => { + const { conditionEvaluations } = formatV2Definition() + + expect(conditionEvaluations).toHaveLength(3) + expect(conditionEvaluations?.[0]).toMatchObject({ + conditionId: 'd15aff7a-6224-40a2-8e5f-51a5af2f7910', + outcome: 'true', + references: [ + { + componentId: '87b987e8-bcf9-4ff9-92af-57c34c45995a', + componentName: 'userName', + answered: true + } + ] + }) + }) + + it('should be empty, not absent, for a V1 definition', () => { + // forms-notify-listener treats an absent property as "this message + // predates conditional emails" and resolves the recipients itself, so + // every message published from here has to carry the property + expect(formatV1Definition().conditionEvaluations).toEqual([]) + }) + + it('should produce a payload the schema accepts', () => { + // The runner publishes with allowUnknown: false and throws on failure, so + // a formatter and schema that disagree would fail every submission + for (const payload of [formatV1Definition(), formatV2Definition()]) { + const { error } = formAdapterSubmissionMessagePayloadSchema.validate( + payload, + { abortEarly: false, allowUnknown: false } + ) + + expect(error).toBeUndefined() + } + }) +}) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index a548185bd..00a86f827 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -1,4 +1,5 @@ import { + Engine, type FormMetadata, type SubmitResponsePayload } from '@defra/forms-model' @@ -11,6 +12,7 @@ import { import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' import { categoriseData } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' +import { buildConditionEvaluations } from '~/src/server/plugins/engine/pageControllers/helpers/submission.js' import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/enums.js' import { type FormAdapterSubmissionMessageData, @@ -73,7 +75,19 @@ export function format( const payload: FormAdapterSubmissionMessagePayload = { meta, data, - result + result, + + // Recorded here because only the engine holds the walked evaluation state + // the conditions were judged against; forms-notify-listener receives the + // flat submitted answers and resolves the outputs that qualify from these + // outcomes alone. Condition ids are only stable in V2, so a V1 definition + // has nothing to report - the property is still emitted, because its + // absence is what marks a message as predating this and sends the listener + // down its legacy path. + conditionEvaluations: + model.engine === Engine.V2 + ? buildConditionEvaluations(model, context) + : [] } return JSON.stringify(payload) diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts index 25ad933d5..5d47907ad 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts @@ -1,3 +1,5 @@ +import { ConditionEvaluationOutcome } from '@defra/forms-model' + import { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' @@ -5,12 +7,19 @@ import { validSingleState } from '~/src/server/plugins/engine/components/helpers import { FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { + buildConditionEvaluations, buildMainRecords, buildPaymentRecords, - buildRepeaterRecords + buildRepeaterRecords, + isAnswered } from '~/src/server/plugins/engine/pageControllers/helpers/submission.js' -import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { + type FormContext, + type FormState, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' import { definition } from '~/test/fixtures/form.js' +import joinedConditionsDefinition from '~/test/form/definitions/joined-conditions-simple-v2.js' const translator = new FormModel(definition, { basePath: '/' @@ -474,3 +483,174 @@ describe('Submission helpers', () => { }) }) }) + +describe('buildConditionEvaluations', () => { + const userNameComponentId = '87b987e8-bcf9-4ff9-92af-57c34c45995a' + const isOverEighteenComponentId = 'c977e76e-49ab-4443-b93e-e19e8d9c81ac' + const isBobConditionId = 'd15aff7a-6224-40a2-8e5f-51a5af2f7910' + const isOverEighteenConditionId = 'd1f9fcc7-f098-47e7-9d31-4f5ee57ba985' + const joinedConditionId = 'db43c6bc-9ce6-478b-8345-4fff5eff2ba3' + + const model = new FormModel(joinedConditionsDefinition, { basePath: '/' }) + + /** + * The engine seeds every component with `null` before the page walk, so an + * unanswered form reaches submission with keys present but empty + * @param {FormState} evaluationState + */ + const build = (evaluationState: FormState) => + buildConditionEvaluations(model, { evaluationState } as FormContext) + + it('should ignore condition if condition not found', () => { + const badModel = new FormModel(joinedConditionsDefinition, { + basePath: '/' + }) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete badModel.conditions[isBobConditionId] + const badBuild = (evaluationState: FormState) => + buildConditionEvaluations(badModel, { evaluationState } as FormContext) + + const evaluations = badBuild({ userName: null, isOverEighteen: null }) + + expect(evaluations.map((evaluation) => evaluation.conditionId)).toEqual([ + isOverEighteenConditionId, + joinedConditionId + ]) + }) + + it('should record every condition in the definition', () => { + const evaluations = build({ userName: null, isOverEighteen: null }) + + expect(evaluations.map((evaluation) => evaluation.conditionId)).toEqual([ + isBobConditionId, + isOverEighteenConditionId, + joinedConditionId + ]) + }) + + it('should record answered conditions that match', () => { + const evaluations = build({ userName: 'Bob', isOverEighteen: true }) + + expect(evaluations).toEqual([ + { + conditionId: isBobConditionId, + outcome: ConditionEvaluationOutcome.True, + references: [ + { + componentId: userNameComponentId, + componentName: 'userName', + answered: true + } + ] + }, + { + conditionId: isOverEighteenConditionId, + outcome: ConditionEvaluationOutcome.True, + references: [ + { + componentId: isOverEighteenComponentId, + componentName: 'isOverEighteen', + answered: true + } + ] + }, + { + conditionId: joinedConditionId, + outcome: ConditionEvaluationOutcome.True, + references: [ + { + componentId: userNameComponentId, + componentName: 'userName', + answered: true + }, + { + componentId: isOverEighteenComponentId, + componentName: 'isOverEighteen', + answered: true + } + ] + } + ]) + }) + + it('should record answered conditions that do not match', () => { + const evaluations = build({ userName: 'Alice', isOverEighteen: false }) + + expect( + evaluations.map(({ conditionId, outcome }) => ({ conditionId, outcome })) + ).toEqual([ + { + conditionId: isBobConditionId, + outcome: ConditionEvaluationOutcome.False + }, + { + conditionId: isOverEighteenConditionId, + outcome: ConditionEvaluationOutcome.False + }, + { + conditionId: joinedConditionId, + outcome: ConditionEvaluationOutcome.False + } + ]) + }) + + it('should flag unanswered references so a vacuous outcome can be spotted', () => { + const evaluations = build({ userName: null, isOverEighteen: null }) + + expect(evaluations[0].outcome).toBe(ConditionEvaluationOutcome.False) + expect(evaluations[0].references).toEqual([ + { + componentId: userNameComponentId, + componentName: 'userName', + answered: false + } + ]) + }) + + it('should treat an empty answer as unanswered', () => { + const evaluations = build({ userName: '', isOverEighteen: null }) + + expect(evaluations[0].references[0].answered).toBe(false) + }) + + it('should flatten nested condition references to their components', () => { + const evaluations = build({ userName: 'Bob', isOverEighteen: null }) + const joined = evaluations.find( + ({ conditionId }) => conditionId === joinedConditionId + ) + + expect(joined?.references).toEqual([ + { + componentId: userNameComponentId, + componentName: 'userName', + answered: true + }, + { + componentId: isOverEighteenComponentId, + componentName: 'isOverEighteen', + answered: false + } + ]) + }) + + it('should record an error outcome when evaluation throws', () => { + // A component missing from the evaluation state - a repeater field, say - + // throws `undefined variable` rather than evaluating to false + const evaluations = build({}) + + expect(evaluations[0].outcome).toBe(ConditionEvaluationOutcome.Error) + }) + + it('should return no evaluations for a V1 definition', () => { + const v1Model = new FormModel(definition, { basePath: '/' }) + + expect( + buildConditionEvaluations(v1Model, { evaluationState: {} } as FormContext) + ).toEqual([]) + }) + + it('isAnswered handle arrays', () => { + expect(isAnswered([])).toBe(false) + expect(isAnswered(['abc'])).toBe(true) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts index 69590a18a..3f3014386 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts @@ -1,13 +1,27 @@ -import { type SubmitPayload } from '@defra/forms-model' +import { + isConditionWrapperV2, + type ConditionDataV2, + type ConditionRefDataV2, + type ConditionWrapperV2, + type SubmitConditionEvaluation, + type SubmitConditionReference, + type SubmitPayload +} from '@defra/forms-model' import { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type DetailItem, type DetailItemField } from '~/src/server/plugins/engine/models/types.js' +import { + type FormContext, + type FormState, + type FormStateValue +} from '~/src/server/plugins/engine/types.js' import { formatCurrency, formatPaymentDate @@ -141,3 +155,125 @@ export function buildRepeaterRecords( ) })) } + +/** + * Records the outcome of every condition in the form definition, evaluated + * against the answers as they stand at the point of submission. + * + * Evaluated here rather than by the consumer because only the engine holds + * the context to evaluate against: `evaluationState` is built by walking the + * form from its start page along the path taken, applying page conditions as + * it goes. A consumer receives the flat submitted answers, not that walked + * state, and reproducing the walk in a second codebase would leave two + * implementations of the same logic free to diverge. + * + * Each record carries the components the condition depends on and whether each + * was answered. An unanswered question still yields a boolean - negative + * operators such as "is not" return `true` against the seeded `null` - so the + * outcome alone cannot be read as evidence that the user gave that answer. + * + * V2 definitions only. `conditionId` is the V2 condition id, and the + * references are resolved from the component ids V2 conditions carry - V1 + * conditions reference components by name, and V1 components need not have + * an id at all. + */ +export function buildConditionEvaluations( + model: FormModel, + context: FormContext +): SubmitConditionEvaluation[] { + const { evaluationState } = context + + return model.def.conditions + .filter(isConditionWrapperV2) + .flatMap((conditionDef) => { + const condition = model.conditions[conditionDef.id] + + if (!condition) { + return [] + } + + const { outcome } = condition.evaluate(evaluationState) + + const references = collectReferences(model, conditionDef, evaluationState) + + return { + conditionId: conditionDef.id, + outcome, + references: [...references.values()] + } + }) +} + +/** + * Whether a component held an answer at the point a condition was evaluated. + * + * The engine seeds every component in `evaluationState` with `null` before the + * page walk begins, so an unanswered question is present but empty rather than + * absent. + * @see {@link FormModel.initialiseContext} + */ +export function isAnswered(value: FormStateValue | undefined) { + if (value === undefined || value === null) { + return false + } + + if (Array.isArray(value)) { + return value.length > 0 + } + + return value !== '' +} + +function isConditionDataV2( + item: ConditionDataV2 | ConditionRefDataV2 +): item is ConditionDataV2 { + return 'componentId' in item +} + +/** + * Recursive function that collects every component a condition depends on, following nested condition + * references. Results are keyed by component id so a component referenced more + * than once is reported once. + */ +function collectReferences( + model: FormModel, + conditionDef: ConditionWrapperV2, + evaluationState: FormState, + references: Map = new Map< + string, + SubmitConditionReference + >(), + visited: Set = new Set() +) { + if (visited.has(conditionDef.id)) { + return references + } + + visited.add(conditionDef.id) + + for (const item of conditionDef.items) { + if (isConditionDataV2(item)) { + const component = model.getComponentById(item.componentId) + + // A condition referencing a component that no longer exists cannot be + // resolved to a name, so there is nothing meaningful to report for it + if (component) { + references.set(item.componentId, { + componentId: item.componentId, + componentName: component.name, + answered: isAnswered(evaluationState[component.name]) + }) + } + + continue + } + + const referenced = model.getConditionById(item.conditionId) + + if (referenced) { + collectReferences(model, referenced, evaluationState, references, visited) + } + } + + return references +} diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 76a96349c..bb67c3ab8 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -6,6 +6,7 @@ import { type List, type Page, type PaymentFieldComponent, + type SubmitConditionEvaluation, type UkAddressFieldComponent } from '@defra/forms-model' import { @@ -529,7 +530,7 @@ export interface PluginOptions { } export interface FormAdapterSubmissionMessageMeta { - schemaVersion: FormAdapterSubmissionSchemaVersion + schemaVersion: (typeof FormAdapterSubmissionSchemaVersion)[keyof typeof FormAdapterSubmissionSchemaVersion] timestamp: Date referenceNumber: string formName: string @@ -607,6 +608,18 @@ export interface FormAdapterSubmissionMessagePayload { meta: FormAdapterSubmissionMessageMeta data: FormAdapterSubmissionMessageData result: FormAdapterSubmissionMessageResult + + /** + * The outcome of every condition in the form definition, evaluated against + * the final answers at the point of submission. + * + * Always emitted, empty when there is nothing to report - a V1-engine form + * has no stable condition ids to record against. Optional only for + * backwards compatibility: a message published before this existed carries + * no evaluations at all, and consumers fall back to resolving the recipients + * from the form definition themselves. + */ + conditionEvaluations?: SubmitConditionEvaluation[] } export interface FormAdapterSubmissionMessage extends FormAdapterSubmissionMessagePayload { diff --git a/src/server/plugins/engine/types/enums.ts b/src/server/plugins/engine/types/enums.ts index c7b38428e..b0ac3a135 100644 --- a/src/server/plugins/engine/types/enums.ts +++ b/src/server/plugins/engine/types/enums.ts @@ -10,6 +10,7 @@ export enum FileStatus { pending = 'pending' } -export enum FormAdapterSubmissionSchemaVersion { - V1 = 1 -} +// Changed from an enum to enforce numeric values +export const FormAdapterSubmissionSchemaVersion = { + V1: 1 +} as const satisfies Record diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index 65c4988e4..2a0d43ede 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -253,4 +253,87 @@ describe('Schema validation', () => { expect(error?.message).toContain('must be a number') }) }) + + describe('conditionEvaluations', () => { + const meta: FormAdapterSubmissionMessageMeta = { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date('2025-08-22T18:15:10.785Z'), + referenceNumber: '576-225-943', + formName: 'Order a pizza', + formId: '68a8b0449ab460290c28940a', + formSlug: 'order-a-pizza', + status: FormStatus.Live, + isPreview: false, + notificationEmail: 'info@example.com' + } + + const result = { + files: { main: '3d289230-83a3-4852-a68a-cb3569e9b0fe', repeaters: {} } + } + + const evaluation = { + conditionId: 'd15aff7a-6224-40a2-8e5f-51a5af2f7910', + outcome: 'true', + references: [ + { + componentId: '87b987e8-bcf9-4ff9-92af-57c34c45995a', + componentName: 'userName', + answered: true + } + ] + } + + it('accepts a payload carrying condition evaluations', () => { + const { error } = formAdapterSubmissionMessagePayloadSchema.validate({ + meta, + data: validData, + result, + conditionEvaluations: [evaluation] + }) + expect(error).toBeUndefined() + }) + + it('accepts an empty list - a V1-engine form has nothing to report', () => { + const { error } = formAdapterSubmissionMessagePayloadSchema.validate({ + meta, + data: validData, + result, + conditionEvaluations: [] + }) + expect(error).toBeUndefined() + }) + + it('accepts a payload without them, keeping older messages valid', () => { + const { error } = formAdapterSubmissionMessagePayloadSchema.validate({ + meta, + data: validData, + result + }) + expect(error).toBeUndefined() + }) + + it('preserves the evaluations under stripUnknown', () => { + // forms-notify-listener and forms-submission-api both validate with + // stripUnknown. If the schema did not know about the evaluations they + // would be silently dropped, and the listener would fall back to + // resolving recipients from the live definition. + const { value } = formAdapterSubmissionMessagePayloadSchema.validate( + { meta, data: validData, result, conditionEvaluations: [evaluation] }, + { stripUnknown: true } + ) + const validated = value as FormAdapterSubmissionMessagePayload + + expect(validated.conditionEvaluations).toEqual([evaluation]) + }) + + it('rejects a malformed evaluation', () => { + const { error } = formAdapterSubmissionMessagePayloadSchema.validate({ + meta, + data: validData, + result, + conditionEvaluations: [{ conditionId: 'abc' }] + }) + expect(error).toBeDefined() + }) + }) }) diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index 53567e2ca..9257c449c 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -1,5 +1,6 @@ import { FormStatus, + formSubmitConditionEvaluationSchema, formVersionMetadataSchema, idSchema, notificationEmailAddressSchema, @@ -18,9 +19,9 @@ import { export const formAdapterSubmissionMessageMetaSchema = Joi.object().keys({ - schemaVersion: Joi.string().valid( - ...Object.values(FormAdapterSubmissionSchemaVersion) - ), + schemaVersion: Joi.number() + .valid(...Object.values(FormAdapterSubmissionSchemaVersion)) + .required(), timestamp: Joi.date().required(), referenceNumber: Joi.string().required(), formName: titleSchema, @@ -80,5 +81,16 @@ export const formAdapterSubmissionMessagePayloadSchema = Joi.object().keys({ meta: formAdapterSubmissionMessageMetaSchema.required(), data: formAdapterSubmissionMessageDataSchema.required(), - result: formAdapterSubmissionMessageResultSchema.required() + result: formAdapterSubmissionMessageResultSchema.required(), + // Optional so that messages published before this existed - which may + // still be in flight or sitting on a dead-letter queue - continue to + // validate. Consumers treat its absence as "resolve the recipients from + // the form definition yourself" + // This should be required at a later point in time. + conditionEvaluations: Joi.array() + .items(formSubmitConditionEvaluationSchema) + .optional() + .description( + 'Outcome of every condition in the form definition, evaluated against the final answers at submission' + ) })