@defra/forms-engine-plugin 5.0.0-alpha.20 → 5.0.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.server/server/plugins/engine/helpers.d.ts +1 -0
  2. package/.server/server/plugins/engine/models/FormModel.js +16 -6
  3. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  4. package/.server/server/plugins/engine/models/types.d.ts +28 -1
  5. package/.server/server/plugins/engine/models/types.js.map +1 -1
  6. package/.server/server/plugins/engine/options.js +2 -1
  7. package/.server/server/plugins/engine/options.js.map +1 -1
  8. package/.server/server/plugins/engine/outputFormatters/adapter/v1.js +11 -1
  9. package/.server/server/plugins/engine/outputFormatters/adapter/v1.js.map +1 -1
  10. package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +34 -1
  11. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +97 -0
  12. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -1
  13. package/.server/server/plugins/engine/plugin.js +3 -0
  14. package/.server/server/plugins/engine/plugin.js.map +1 -1
  15. package/.server/server/plugins/engine/referenceNumbers.d.ts +1 -0
  16. package/.server/server/plugins/engine/referenceNumbers.js +1 -0
  17. package/.server/server/plugins/engine/referenceNumbers.js.map +1 -1
  18. package/.server/server/plugins/engine/routes/index.js +9 -4
  19. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  20. package/.server/server/plugins/engine/types/enums.d.ts +3 -3
  21. package/.server/server/plugins/engine/types/enums.js +5 -4
  22. package/.server/server/plugins/engine/types/enums.js.map +1 -1
  23. package/.server/server/plugins/engine/types/schema.js +9 -3
  24. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  25. package/.server/server/plugins/engine/types.d.ts +15 -2
  26. package/.server/server/plugins/engine/types.js.map +1 -1
  27. package/.server/typings/hapi/index.d.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/server/plugins/engine/models/FormModel.ts +25 -6
  30. package/src/server/plugins/engine/models/types.ts +31 -0
  31. package/src/server/plugins/engine/options.js +2 -1
  32. package/src/server/plugins/engine/outputFormatters/adapter/v1.ts +15 -1
  33. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +137 -1
  34. package/src/server/plugins/engine/plugin.ts +6 -0
  35. package/src/server/plugins/engine/referenceNumbers.ts +2 -0
  36. package/src/server/plugins/engine/routes/index.ts +9 -3
  37. package/src/server/plugins/engine/types/enums.ts +4 -3
  38. package/src/server/plugins/engine/types/schema.ts +16 -4
  39. package/src/server/plugins/engine/types.ts +19 -1
  40. package/src/typings/hapi/index.d.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ComponentType,
3
+ ConditionEvaluationOutcome,
3
4
  ConditionsModel,
4
5
  ControllerPath,
5
6
  ControllerType,
@@ -8,6 +9,7 @@ import {
8
9
  formDefinitionSchema,
9
10
  formDefinitionV2Schema,
10
11
  generateConditionAlias,
12
+ getErrorMessage,
11
13
  hasComponents,
12
14
  hasComponentsEvenIfNoNext,
13
15
  hasRepeater,
@@ -54,7 +56,10 @@ import { extractBaseTranslations } from '../i18n/extractBaseTranslations.js'
54
56
  import { createFormI18nInstance } from '../i18n/index.js'
55
57
  import { getAvailableLanguages } from '../i18n/languages.js'
56
58
  import { type Translator } from '../i18n/types.js'
57
- import { type ExecutableCondition } from './types.js'
59
+ import {
60
+ type ConditionEvaluation,
61
+ type ExecutableCondition
62
+ } from './types.js'
58
63
  import { type PageController } from '../pageControllers/PageController.js'
59
64
  import {
60
65
  createPage,
@@ -304,21 +309,35 @@ export class FormModel {
304
309
  throw new ConditionBuildError(displayName, { cause })
305
310
  }
306
311
 
307
- const fn = (evaluationState: FormState) => {
312
+ const evaluate = (evaluationState: FormState): ConditionEvaluation => {
308
313
  const ctx = this.toConditionContext(evaluationState, this.conditions)
314
+
309
315
  try {
310
- return expr.evaluate(ctx) as boolean
311
- } catch {
312
- return false
316
+ return {
317
+ outcome: (expr.evaluate(ctx) as boolean)
318
+ ? ConditionEvaluationOutcome.True
319
+ : ConditionEvaluationOutcome.False
320
+ }
321
+ } catch (err) {
322
+ return {
323
+ outcome: ConditionEvaluationOutcome.Error,
324
+ error: getErrorMessage(err)
325
+ }
313
326
  }
314
327
  }
315
328
 
329
+ // A failed evaluation continues to route as `false`. `evaluate` exists so
330
+ // that the two can be told apart when recording outcomes for submission.
331
+ const fn = (evaluationState: FormState) =>
332
+ evaluate(evaluationState).outcome === ConditionEvaluationOutcome.True
333
+
316
334
  return {
317
335
  name,
318
336
  displayName,
319
337
  value,
320
338
  expr,
321
- fn
339
+ fn,
340
+ evaluate
322
341
  }
323
342
  }
324
343
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ConditionEvaluationOutcome,
2
3
  type ConditionWrapper,
3
4
  type FormComponentsDef,
4
5
  type Section
@@ -16,9 +17,39 @@ import {
16
17
  type FormSubmissionError
17
18
  } from '../types.js'
18
19
 
20
+ /**
21
+ * The result of evaluating a condition, keeping a failed evaluation distinct
22
+ * from one that legitimately returned `false`.
23
+ * @see {@link ExecutableCondition.evaluate}
24
+ */
25
+ export interface ConditionEvaluation {
26
+ outcome: ConditionEvaluationOutcome
27
+ error?: string
28
+ }
29
+
30
+ /**
31
+ * A form condition paired with the parsed expression and callbacks needed to
32
+ * run it against a form submission state
33
+ * Created by `FormModel.makeCondition`
34
+ */
19
35
  export type ExecutableCondition = ConditionWrapper & {
36
+ /**
37
+ * Parsed expression for the condition's {@link ConditionWrapper.value},
38
+ * evaluated against a context built from the submission state
39
+ */
20
40
  expr: Expression
41
+
42
+ /**
43
+ * Evaluates the condition, used for page routing and component visibility
44
+ * A failed evaluation is reported as `false`
45
+ */
21
46
  fn: (evaluationState: FormState) => boolean
47
+
48
+ /**
49
+ * As `fn`, but reports whether evaluation failed rather than defaulting a
50
+ * failure to `false`. Used to record condition outcomes on submission.
51
+ */
52
+ evaluate: (evaluationState: FormState) => ConditionEvaluation
22
53
  }
23
54
 
24
55
  /**
@@ -26,7 +26,8 @@ const pluginRegistrationOptionsSchema = Joi.object({
26
26
  saveAndExit: Joi.function().optional(),
27
27
  ordnanceSurveyApiKey: Joi.string().optional(),
28
28
  ordnanceSurveyApiSecret: Joi.string().optional(),
29
- getLanguage: Joi.function().optional()
29
+ getLanguage: Joi.function().optional(),
30
+ generateReferenceNumber: Joi.function().optional()
30
31
  })
31
32
 
32
33
  /**
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Engine,
2
3
  type FormMetadata,
3
4
  type SubmitResponsePayload
4
5
  } from '@defra/forms-model'
@@ -11,6 +12,7 @@ import {
11
12
  import { type FormModel } from '../../models/FormModel.js'
12
13
  import { type DetailItem } from '../../models/types.js'
13
14
  import { categoriseData } from '../machine/v2.js'
15
+ import { buildConditionEvaluations } from '../../pageControllers/helpers/submission.js'
14
16
  import { FormAdapterSubmissionSchemaVersion } from '../../types/enums.js'
15
17
  import {
16
18
  type FormAdapterSubmissionMessageData,
@@ -73,7 +75,19 @@ export function format(
73
75
  const payload: FormAdapterSubmissionMessagePayload = {
74
76
  meta,
75
77
  data,
76
- result
78
+ result,
79
+
80
+ // Recorded here because only the engine holds the walked evaluation state
81
+ // the conditions were judged against; forms-notify-listener receives the
82
+ // flat submitted answers and resolves the outputs that qualify from these
83
+ // outcomes alone. Condition ids are only stable in V2, so a V1 definition
84
+ // has nothing to report - the property is still emitted, because its
85
+ // absence is what marks a message as predating this and sends the listener
86
+ // down its legacy path.
87
+ conditionEvaluations:
88
+ model.engine === Engine.V2
89
+ ? buildConditionEvaluations(model, context)
90
+ : []
77
91
  }
78
92
 
79
93
  return JSON.stringify(payload)
@@ -1,13 +1,27 @@
1
- import { type SubmitPayload } from '@defra/forms-model'
1
+ import {
2
+ isConditionWrapperV2,
3
+ type ConditionDataV2,
4
+ type ConditionRefDataV2,
5
+ type ConditionWrapperV2,
6
+ type SubmitConditionEvaluation,
7
+ type SubmitConditionReference,
8
+ type SubmitPayload
9
+ } from '@defra/forms-model'
2
10
 
3
11
  import { GeospatialField } from '../../components/GeospatialField.js'
4
12
  import { PaymentField } from '../../components/PaymentField.js'
5
13
  import { getAnswer } from '../../components/helpers/components.js'
6
14
  import { type Translator } from '../../i18n/types.js'
15
+ import { type FormModel } from '../../models/FormModel.js'
7
16
  import {
8
17
  type DetailItem,
9
18
  type DetailItemField
10
19
  } from '../../models/types.js'
20
+ import {
21
+ type FormContext,
22
+ type FormState,
23
+ type FormStateValue
24
+ } from '../../types.js'
11
25
  import {
12
26
  formatCurrency,
13
27
  formatPaymentDate
@@ -141,3 +155,125 @@ export function buildRepeaterRecords(
141
155
  )
142
156
  }))
143
157
  }
158
+
159
+ /**
160
+ * Records the outcome of every condition in the form definition, evaluated
161
+ * against the answers as they stand at the point of submission.
162
+ *
163
+ * Evaluated here rather than by the consumer because only the engine holds
164
+ * the context to evaluate against: `evaluationState` is built by walking the
165
+ * form from its start page along the path taken, applying page conditions as
166
+ * it goes. A consumer receives the flat submitted answers, not that walked
167
+ * state, and reproducing the walk in a second codebase would leave two
168
+ * implementations of the same logic free to diverge.
169
+ *
170
+ * Each record carries the components the condition depends on and whether each
171
+ * was answered. An unanswered question still yields a boolean - negative
172
+ * operators such as "is not" return `true` against the seeded `null` - so the
173
+ * outcome alone cannot be read as evidence that the user gave that answer.
174
+ *
175
+ * V2 definitions only. `conditionId` is the V2 condition id, and the
176
+ * references are resolved from the component ids V2 conditions carry - V1
177
+ * conditions reference components by name, and V1 components need not have
178
+ * an id at all.
179
+ */
180
+ export function buildConditionEvaluations(
181
+ model: FormModel,
182
+ context: FormContext
183
+ ): SubmitConditionEvaluation[] {
184
+ const { evaluationState } = context
185
+
186
+ return model.def.conditions
187
+ .filter(isConditionWrapperV2)
188
+ .flatMap((conditionDef) => {
189
+ const condition = model.conditions[conditionDef.id]
190
+
191
+ if (!condition) {
192
+ return []
193
+ }
194
+
195
+ const { outcome } = condition.evaluate(evaluationState)
196
+
197
+ const references = collectReferences(model, conditionDef, evaluationState)
198
+
199
+ return {
200
+ conditionId: conditionDef.id,
201
+ outcome,
202
+ references: [...references.values()]
203
+ }
204
+ })
205
+ }
206
+
207
+ /**
208
+ * Whether a component held an answer at the point a condition was evaluated.
209
+ *
210
+ * The engine seeds every component in `evaluationState` with `null` before the
211
+ * page walk begins, so an unanswered question is present but empty rather than
212
+ * absent.
213
+ * @see {@link FormModel.initialiseContext}
214
+ */
215
+ export function isAnswered(value: FormStateValue | undefined) {
216
+ if (value === undefined || value === null) {
217
+ return false
218
+ }
219
+
220
+ if (Array.isArray(value)) {
221
+ return value.length > 0
222
+ }
223
+
224
+ return value !== ''
225
+ }
226
+
227
+ function isConditionDataV2(
228
+ item: ConditionDataV2 | ConditionRefDataV2
229
+ ): item is ConditionDataV2 {
230
+ return 'componentId' in item
231
+ }
232
+
233
+ /**
234
+ * Recursive function that collects every component a condition depends on, following nested condition
235
+ * references. Results are keyed by component id so a component referenced more
236
+ * than once is reported once.
237
+ */
238
+ function collectReferences(
239
+ model: FormModel,
240
+ conditionDef: ConditionWrapperV2,
241
+ evaluationState: FormState,
242
+ references: Map<string, SubmitConditionReference> = new Map<
243
+ string,
244
+ SubmitConditionReference
245
+ >(),
246
+ visited: Set<string> = new Set<string>()
247
+ ) {
248
+ if (visited.has(conditionDef.id)) {
249
+ return references
250
+ }
251
+
252
+ visited.add(conditionDef.id)
253
+
254
+ for (const item of conditionDef.items) {
255
+ if (isConditionDataV2(item)) {
256
+ const component = model.getComponentById(item.componentId)
257
+
258
+ // A condition referencing a component that no longer exists cannot be
259
+ // resolved to a name, so there is nothing meaningful to report for it
260
+ if (component) {
261
+ references.set(item.componentId, {
262
+ componentId: item.componentId,
263
+ componentName: component.name,
264
+ answered: isAnswered(evaluationState[component.name])
265
+ })
266
+ }
267
+
268
+ continue
269
+ }
270
+
271
+ const referenced = model.getConditionById(item.conditionId)
272
+
273
+ if (referenced) {
274
+ collectReferences(model, referenced, evaluationState, references, visited)
275
+ }
276
+ }
277
+
278
+ return references
279
+ }
@@ -8,6 +8,7 @@ import {
8
8
 
9
9
  import { type FormModel } from './models/index.js'
10
10
  import { validatePluginOptions } from './options.js'
11
+ import { generateUniqueReference } from './referenceNumbers.js'
11
12
  import { getRoutes as getFileUploadStatusRoutes } from './routes/file-upload.js'
12
13
  import { makeLoadFormPreHandler } from './routes/index.js'
13
14
  import { getRoutes as getPaymentRoutes } from './routes/payment.js'
@@ -37,6 +38,7 @@ export const plugin = {
37
38
  cache,
38
39
  saveAndExit,
39
40
  getLanguage,
41
+ generateReferenceNumber,
40
42
  nunjucks: nunjucksOptions,
41
43
  viewContext,
42
44
  preparePageEventRequestOptions,
@@ -80,6 +82,10 @@ export const plugin = {
80
82
  server.expose('cacheService', cacheService)
81
83
  server.expose('saveAndExit', saveAndExit)
82
84
  server.expose('getLanguage', getLanguage)
85
+ server.expose(
86
+ 'generateReferenceNumber',
87
+ generateReferenceNumber ?? generateUniqueReference
88
+ )
83
89
  server.expose('baseUrl', baseUrl)
84
90
  server.expose('services', services)
85
91
 
@@ -6,6 +6,8 @@ import {
6
6
  englishRecommendedTransformers
7
7
  } from 'obscenity'
8
8
 
9
+ export const DEFAULT_REFERENCE_NUMBER = 'XXX-XXX-XXX'
10
+
9
11
  /**
10
12
  * To prevent confusion to users reading the reference number, ambiguous letters and numbers are removed.
11
13
  * @param strCodes - array of binary input values
@@ -20,6 +20,7 @@ import {
20
20
  findPage,
21
21
  getCacheService,
22
22
  getPage,
23
+ getPluginOptions,
23
24
  getStartPath,
24
25
  proceed
25
26
  } from '../helpers.js'
@@ -28,7 +29,7 @@ import {
28
29
  checkSaveAndExitRepeater,
29
30
  copyNotYetValidatedState
30
31
  } from '../pageControllers/helpers/state.js'
31
- import { generateUniqueReference } from '../referenceNumbers.js'
32
+ import { DEFAULT_REFERENCE_NUMBER } from '../referenceNumbers.js'
32
33
  import * as defaultServices from '../services/index.js'
33
34
  import {
34
35
  type AnyFormRequest,
@@ -54,7 +55,7 @@ export async function redirectOrMakeHandler(
54
55
  context: FormContext
55
56
  ) => ResponseObject | Promise<ResponseObject>
56
57
  ) {
57
- const { app, params } = request
58
+ const { app, params, query } = request
58
59
  const { model } = app
59
60
 
60
61
  if (!model) {
@@ -74,7 +75,12 @@ export async function redirectOrMakeHandler(
74
75
  )
75
76
  }
76
77
 
77
- const referenceNumber = generateUniqueReference(prefix)
78
+ const isForce = 'force' in query
79
+ const { generateReferenceNumber } = getPluginOptions(request.server)
80
+ const referenceNumber = isForce
81
+ ? DEFAULT_REFERENCE_NUMBER
82
+ : await generateReferenceNumber(prefix)
83
+
78
84
  state = await page.mergeState(request, state, {
79
85
  $$__referenceNumber: referenceNumber
80
86
  })
@@ -10,6 +10,7 @@ export enum FileStatus {
10
10
  pending = 'pending'
11
11
  }
12
12
 
13
- export enum FormAdapterSubmissionSchemaVersion {
14
- V1 = 1
15
- }
13
+ // Changed from an enum to enforce numeric values
14
+ export const FormAdapterSubmissionSchemaVersion = {
15
+ V1: 1
16
+ } as const satisfies Record<string, number>
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  FormStatus,
3
+ formSubmitConditionEvaluationSchema,
3
4
  formVersionMetadataSchema,
4
5
  idSchema,
5
6
  notificationEmailAddressSchema,
@@ -18,9 +19,9 @@ import {
18
19
 
19
20
  export const formAdapterSubmissionMessageMetaSchema =
20
21
  Joi.object<FormAdapterSubmissionMessageMeta>().keys({
21
- schemaVersion: Joi.string().valid(
22
- ...Object.values(FormAdapterSubmissionSchemaVersion)
23
- ),
22
+ schemaVersion: Joi.number()
23
+ .valid(...Object.values(FormAdapterSubmissionSchemaVersion))
24
+ .required(),
24
25
  timestamp: Joi.date().required(),
25
26
  referenceNumber: Joi.string().required(),
26
27
  formName: titleSchema,
@@ -80,5 +81,16 @@ export const formAdapterSubmissionMessagePayloadSchema =
80
81
  Joi.object<FormAdapterSubmissionMessagePayload>().keys({
81
82
  meta: formAdapterSubmissionMessageMetaSchema.required(),
82
83
  data: formAdapterSubmissionMessageDataSchema.required(),
83
- result: formAdapterSubmissionMessageResultSchema.required()
84
+ result: formAdapterSubmissionMessageResultSchema.required(),
85
+ // Optional so that messages published before this existed - which may
86
+ // still be in flight or sitting on a dead-letter queue - continue to
87
+ // validate. Consumers treat its absence as "resolve the recipients from
88
+ // the form definition yourself"
89
+ // This should be required at a later point in time.
90
+ conditionEvaluations: Joi.array()
91
+ .items(formSubmitConditionEvaluationSchema)
92
+ .optional()
93
+ .description(
94
+ 'Outcome of every condition in the form definition, evaluated against the final answers at submission'
95
+ )
84
96
  })
@@ -6,6 +6,7 @@ import {
6
6
  type List,
7
7
  type Page,
8
8
  type PaymentFieldComponent,
9
+ type SubmitConditionEvaluation,
9
10
  type UkAddressFieldComponent
10
11
  } from '@defra/forms-model'
11
12
  import {
@@ -483,6 +484,10 @@ export type SaveAndExitHandler = (
483
484
 
484
485
  export type GetLanguageHandler = (query?: RequestQuery, yar?: Yar) => string
485
486
 
487
+ export type GenerateReferenceNumberHandler = (
488
+ prefix?: string
489
+ ) => Promise<string>
490
+
486
491
  export interface ExternalArgs {
487
492
  component: ComponentDef
488
493
  controller: QuestionPageController
@@ -523,13 +528,14 @@ export interface PluginOptions {
523
528
  preparePageEventRequestOptions?: PreparePageEventRequestOptions
524
529
  onRequest?: OnRequestCallback
525
530
  getLanguage?: GetLanguageHandler
531
+ generateReferenceNumber?: GenerateReferenceNumberHandler
526
532
  baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com"
527
533
  ordnanceSurveyApiKey?: string
528
534
  ordnanceSurveyApiSecret?: string
529
535
  }
530
536
 
531
537
  export interface FormAdapterSubmissionMessageMeta {
532
- schemaVersion: FormAdapterSubmissionSchemaVersion
538
+ schemaVersion: (typeof FormAdapterSubmissionSchemaVersion)[keyof typeof FormAdapterSubmissionSchemaVersion]
533
539
  timestamp: Date
534
540
  referenceNumber: string
535
541
  formName: string
@@ -607,6 +613,18 @@ export interface FormAdapterSubmissionMessagePayload {
607
613
  meta: FormAdapterSubmissionMessageMeta
608
614
  data: FormAdapterSubmissionMessageData
609
615
  result: FormAdapterSubmissionMessageResult
616
+
617
+ /**
618
+ * The outcome of every condition in the form definition, evaluated against
619
+ * the final answers at the point of submission.
620
+ *
621
+ * Always emitted, empty when there is nothing to report - a V1-engine form
622
+ * has no stable condition ids to record against. Optional only for
623
+ * backwards compatibility: a message published before this existed carries
624
+ * no evaluations at all, and consumers fall back to resolving the recipients
625
+ * from the form definition themselves.
626
+ */
627
+ conditionEvaluations?: SubmitConditionEvaluation[]
610
628
  }
611
629
 
612
630
  export interface FormAdapterSubmissionMessage extends FormAdapterSubmissionMessagePayload {
@@ -11,6 +11,7 @@ import { type FormModel } from '../../server/plugins/engine/models/index.js'
11
11
  import {
12
12
  type AnyFormRequest,
13
13
  type FormSubmissionError,
14
+ type GenerateReferenceNumberHandler,
14
15
  type PluginOptions
15
16
  } from '../../server/plugins/engine/types.ts'
16
17
  import { type CacheService } from '../../server/services/index.js'
@@ -41,6 +42,7 @@ declare module '@hapi/hapi' {
41
42
  getLanguage?: PluginOptions['getLanguage']
42
43
  baseUrl: string
43
44
  services: PluginOptions['services']
45
+ generateReferenceNumber: GenerateReferenceNumberHandler
44
46
  }
45
47
  }
46
48