@dwp/govuk-casa 8.7.9 → 8.8.0

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 (175) hide show
  1. package/README.md +0 -5
  2. package/dist/casa.d.ts +1 -1
  3. package/dist/casa.js +3 -2
  4. package/dist/casa.js.map +1 -0
  5. package/dist/lib/CasaTemplateLoader.d.ts +1 -7
  6. package/dist/lib/CasaTemplateLoader.js +6 -1
  7. package/dist/lib/CasaTemplateLoader.js.map +1 -0
  8. package/dist/lib/JourneyContext.d.ts +1 -1
  9. package/dist/lib/JourneyContext.js +2 -1
  10. package/dist/lib/JourneyContext.js.map +1 -0
  11. package/dist/lib/MutableRouter.js +1 -0
  12. package/dist/lib/MutableRouter.js.map +1 -0
  13. package/dist/lib/Plan.d.ts +2 -0
  14. package/dist/lib/Plan.js +9 -4
  15. package/dist/lib/Plan.js.map +1 -0
  16. package/dist/lib/ValidationError.js +2 -1
  17. package/dist/lib/ValidationError.js.map +1 -0
  18. package/dist/lib/ValidatorFactory.d.ts +2 -2
  19. package/dist/lib/ValidatorFactory.js +3 -2
  20. package/dist/lib/ValidatorFactory.js.map +1 -0
  21. package/dist/lib/configuration-ingestor.d.ts +1 -1
  22. package/dist/lib/configuration-ingestor.js +2 -1
  23. package/dist/lib/configuration-ingestor.js.map +1 -0
  24. package/dist/lib/configure.js +2 -1
  25. package/dist/lib/configure.js.map +1 -0
  26. package/dist/lib/end-session.js +1 -0
  27. package/dist/lib/end-session.js.map +1 -0
  28. package/dist/lib/field.js +1 -0
  29. package/dist/lib/field.js.map +1 -0
  30. package/dist/lib/index.js +1 -0
  31. package/dist/lib/index.js.map +1 -0
  32. package/dist/lib/logger.js +1 -0
  33. package/dist/lib/logger.js.map +1 -0
  34. package/dist/lib/mount.js +3 -2
  35. package/dist/lib/mount.js.map +1 -0
  36. package/dist/lib/nunjucks-filters.js +28 -1
  37. package/dist/lib/nunjucks-filters.js.map +1 -0
  38. package/dist/lib/nunjucks.js +1 -0
  39. package/dist/lib/nunjucks.js.map +1 -0
  40. package/dist/lib/utils.d.ts +46 -28
  41. package/dist/lib/utils.js +105 -67
  42. package/dist/lib/utils.js.map +1 -0
  43. package/dist/lib/validators/dateObject.js +5 -4
  44. package/dist/lib/validators/dateObject.js.map +1 -0
  45. package/dist/lib/validators/email.js +1 -0
  46. package/dist/lib/validators/email.js.map +1 -0
  47. package/dist/lib/validators/inArray.js +1 -0
  48. package/dist/lib/validators/inArray.js.map +1 -0
  49. package/dist/lib/validators/index.js +1 -0
  50. package/dist/lib/validators/index.js.map +1 -0
  51. package/dist/lib/validators/nino.js +1 -0
  52. package/dist/lib/validators/nino.js.map +1 -0
  53. package/dist/lib/validators/postalAddressObject.d.ts +2 -2
  54. package/dist/lib/validators/postalAddressObject.js +2 -1
  55. package/dist/lib/validators/postalAddressObject.js.map +1 -0
  56. package/dist/lib/validators/regex.js +1 -0
  57. package/dist/lib/validators/regex.js.map +1 -0
  58. package/dist/lib/validators/required.d.ts +1 -1
  59. package/dist/lib/validators/required.js +2 -1
  60. package/dist/lib/validators/required.js.map +1 -0
  61. package/dist/lib/validators/strlen.js +1 -0
  62. package/dist/lib/validators/strlen.js.map +1 -0
  63. package/dist/lib/validators/wordCount.js +1 -0
  64. package/dist/lib/validators/wordCount.js.map +1 -0
  65. package/dist/lib/waypoint-url.js +1 -0
  66. package/dist/lib/waypoint-url.js.map +1 -0
  67. package/dist/middleware/body-parser.js +1 -0
  68. package/dist/middleware/body-parser.js.map +1 -0
  69. package/dist/middleware/csrf.js +1 -0
  70. package/dist/middleware/csrf.js.map +1 -0
  71. package/dist/middleware/data.js +1 -0
  72. package/dist/middleware/data.js.map +1 -0
  73. package/dist/middleware/gather-fields.js +2 -1
  74. package/dist/middleware/gather-fields.js.map +1 -0
  75. package/dist/middleware/i18n.js +1 -0
  76. package/dist/middleware/i18n.js.map +1 -0
  77. package/dist/middleware/post.js +1 -0
  78. package/dist/middleware/post.js.map +1 -0
  79. package/dist/middleware/pre.js +1 -0
  80. package/dist/middleware/pre.js.map +1 -0
  81. package/dist/middleware/progress-journey.js +1 -0
  82. package/dist/middleware/progress-journey.js.map +1 -0
  83. package/dist/middleware/sanitise-fields.js +1 -0
  84. package/dist/middleware/sanitise-fields.js.map +1 -0
  85. package/dist/middleware/serve-first-waypoint.js +1 -0
  86. package/dist/middleware/serve-first-waypoint.js.map +1 -0
  87. package/dist/middleware/session.js +1 -0
  88. package/dist/middleware/session.js.map +1 -0
  89. package/dist/middleware/skip-waypoint.js +1 -0
  90. package/dist/middleware/skip-waypoint.js.map +1 -0
  91. package/dist/middleware/steer-journey.js +1 -0
  92. package/dist/middleware/steer-journey.js.map +1 -0
  93. package/dist/middleware/strip-proxy-path.js +2 -1
  94. package/dist/middleware/strip-proxy-path.js.map +1 -0
  95. package/dist/middleware/validate-fields.js +2 -1
  96. package/dist/middleware/validate-fields.js.map +1 -0
  97. package/dist/mjs/esm-wrapper.js +10 -15
  98. package/dist/routes/ancillary.js +1 -0
  99. package/dist/routes/ancillary.js.map +1 -0
  100. package/dist/routes/journey.js +1 -0
  101. package/dist/routes/journey.js.map +1 -0
  102. package/dist/routes/static.js +1 -0
  103. package/dist/routes/static.js.map +1 -0
  104. package/locales/cy/error.json +1 -1
  105. package/locales/en/error.json +1 -1
  106. package/package.json +20 -19
  107. package/src/casa.js +320 -0
  108. package/src/lib/CasaTemplateLoader.js +104 -0
  109. package/src/lib/JourneyContext.js +783 -0
  110. package/src/lib/MutableRouter.js +310 -0
  111. package/src/lib/Plan.js +624 -0
  112. package/src/lib/ValidationError.js +163 -0
  113. package/src/lib/ValidatorFactory.js +105 -0
  114. package/src/lib/configuration-ingestor.js +457 -0
  115. package/src/lib/configure.js +202 -0
  116. package/src/lib/dirname.cjs +1 -0
  117. package/src/lib/end-session.js +45 -0
  118. package/src/lib/field.js +456 -0
  119. package/src/lib/index.js +33 -0
  120. package/src/lib/logger.js +16 -0
  121. package/src/lib/mount.js +127 -0
  122. package/src/lib/nunjucks-filters.js +150 -0
  123. package/src/lib/nunjucks.js +53 -0
  124. package/src/lib/utils.js +232 -0
  125. package/src/lib/validators/dateObject.js +169 -0
  126. package/src/lib/validators/email.js +55 -0
  127. package/src/lib/validators/inArray.js +81 -0
  128. package/src/lib/validators/index.js +24 -0
  129. package/src/lib/validators/nino.js +57 -0
  130. package/src/lib/validators/postalAddressObject.js +162 -0
  131. package/src/lib/validators/regex.js +48 -0
  132. package/src/lib/validators/required.js +74 -0
  133. package/src/lib/validators/strlen.js +66 -0
  134. package/src/lib/validators/wordCount.js +70 -0
  135. package/src/lib/waypoint-url.js +93 -0
  136. package/src/middleware/body-parser.js +31 -0
  137. package/src/middleware/csrf.js +29 -0
  138. package/src/middleware/data.js +105 -0
  139. package/src/middleware/dirname.cjs +1 -0
  140. package/src/middleware/gather-fields.js +51 -0
  141. package/src/middleware/i18n.js +106 -0
  142. package/src/middleware/post.js +61 -0
  143. package/src/middleware/pre.js +91 -0
  144. package/src/middleware/progress-journey.js +92 -0
  145. package/src/middleware/sanitise-fields.js +58 -0
  146. package/src/middleware/serve-first-waypoint.js +28 -0
  147. package/src/middleware/session.js +129 -0
  148. package/src/middleware/skip-waypoint.js +46 -0
  149. package/src/middleware/steer-journey.js +78 -0
  150. package/src/middleware/strip-proxy-path.js +56 -0
  151. package/src/middleware/validate-fields.js +84 -0
  152. package/src/routes/ancillary.js +29 -0
  153. package/src/routes/dirname.cjs +1 -0
  154. package/src/routes/journey.js +212 -0
  155. package/src/routes/static.js +77 -0
  156. package/views/casa/components/character-count/README.md +10 -0
  157. package/views/casa/components/character-count/template.njk +6 -2
  158. package/views/casa/components/checkboxes/README.md +45 -37
  159. package/views/casa/components/checkboxes/template.njk +8 -7
  160. package/views/casa/components/date-input/README.md +15 -3
  161. package/views/casa/components/date-input/template.njk +6 -4
  162. package/views/casa/components/input/README.md +9 -0
  163. package/views/casa/components/input/template.njk +6 -2
  164. package/views/casa/components/journey-form/README.md +3 -2
  165. package/views/casa/components/postal-address-object/README.md +10 -0
  166. package/views/casa/components/postal-address-object/template.njk +20 -5
  167. package/views/casa/components/radios/README.md +51 -26
  168. package/views/casa/components/radios/template.njk +6 -3
  169. package/views/casa/components/select/README.md +65 -0
  170. package/views/casa/components/select/macro.njk +3 -0
  171. package/views/casa/components/select/template.njk +49 -0
  172. package/views/casa/components/textarea/README.md +9 -0
  173. package/views/casa/components/textarea/template.njk +6 -2
  174. package/views/casa/layouts/journey.njk +1 -1
  175. package/views/casa/layouts/main.njk +1 -1
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Represents the state of a user's journey through the Plan. It contains
3
+ * information about:
4
+ *
5
+ * - Data gathered during the journey
6
+ * - Validation errors on that data
7
+ * - Navigation information about how the user got where they are.
8
+ */
9
+ import { v4 as uuidv4, validate as uuidValidate } from 'uuid';
10
+ import lodash from 'lodash';
11
+ import ValidationError from './ValidationError.js';
12
+ import logger from './logger.js';
13
+ import { notProto } from './utils.js';
14
+
15
+ const {
16
+ cloneDeep, isPlainObject, isObject, has, isEqual,
17
+ } = lodash; // CommonJS
18
+
19
+ const log = logger('lib:journey-context');
20
+
21
+ /**
22
+ * @access private
23
+ * @typedef {import('../casa').Page} Page
24
+ */
25
+
26
+ /**
27
+ * @access private
28
+ * @typedef {import('../casa').ContextEventHandler} ContextEventHandler
29
+ */
30
+
31
+ /**
32
+ * @access private
33
+ * @typedef {import('../casa').ContextEvent} ContextEvent
34
+ */
35
+
36
+ /**
37
+ * @access private
38
+ * @typedef {import('../casa').JourneyContextObject} JourneyContextObject
39
+ */
40
+
41
+ /**
42
+ * @access private
43
+ * @typedef {import('express').Request} ExpressRequest
44
+ */
45
+
46
+ export function validateObjectKey(key = '') {
47
+ const keyLower = String.prototype.toLowerCase.call(key);
48
+ if (keyLower === 'prototype' || keyLower === '__proto__' || keyLower === 'constructor') {
49
+ throw new SyntaxError(`Invalid object key used, ${key}`);
50
+ }
51
+ return String(key);
52
+ }
53
+
54
+ /**
55
+ * @memberof module:@dwp/govuk-casa
56
+ */
57
+ export default class JourneyContext {
58
+ // Private properties
59
+ #data;
60
+
61
+ #validation;
62
+
63
+ #nav;
64
+
65
+ #identity;
66
+
67
+ #eventListeners;
68
+
69
+ #eventListenerPreState;
70
+
71
+ static DEFAULT_CONTEXT_ID = 'default';
72
+
73
+ /**
74
+ * Constructor.
75
+ *
76
+ * `data` is the "single source of truth" for all data gathered during the
77
+ * user's journey. This is referred to as the "canonical data model".
78
+ * Page-specific "views" of this data are generated at runtime in order to
79
+ * populate/validate specific form fields.
80
+ *
81
+ * `validation` holds the results of form field validation carried out when
82
+ * page forms are POSTed. These results are mapped directly to per-page,
83
+ * per-field.
84
+ *
85
+ * `nav` holds information about the current navigation state. Currently this
86
+ * comprises of the language in which the user is navigating the service.
87
+ *
88
+ * `identity` holds information that helps uniquely identify this context
89
+ * among a group of contexts stored in the session.
90
+ *
91
+ * @param {Record<string,any>} data Entire journey data.
92
+ * @param {object} validation Page errors (indexed by waypoint id).
93
+ * @param {object} nav Navigation context.
94
+ * @param {object} identity Some metadata for identifying this context among others.
95
+ */
96
+ constructor(data = {}, validation = {}, nav = {}, identity = {}) {
97
+ this.#data = data;
98
+ this.#validation = validation;
99
+ this.#nav = nav;
100
+ this.#identity = identity;
101
+ this.#eventListeners = [];
102
+ this.#eventListenerPreState = null;
103
+ }
104
+
105
+ /**
106
+ * Clone into an object that can be stringified.
107
+ *
108
+ * @returns {JourneyContextObject} Plain object.
109
+ */
110
+ toObject() {
111
+ return Object.assign(Object.create(null), {
112
+ data: cloneDeep(this.#data),
113
+ validation: cloneDeep(this.#validation),
114
+ nav: cloneDeep(this.#nav),
115
+ identity: cloneDeep(this.#identity),
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Create a new JourneyContext using the plain object.
121
+ *
122
+ * @param {JourneyContextObject} obj Object.
123
+ * @returns {JourneyContext} Instance.
124
+ */
125
+ static fromObject({
126
+ data = Object.create(null),
127
+ validation = Object.create(null),
128
+ nav = Object.create(null),
129
+ identity = Object.create(null),
130
+ } = {}) {
131
+ // As we're constructing a JourneyContext from a plain JS object, we need to
132
+ // ensure any validation errors are instances of ValidationError.
133
+ const deserialisedValidation = Object.create(null);
134
+ for (const [waypoint, errors] of Object.entries(validation)) {
135
+ let dErrors = errors;
136
+
137
+ if (Array.isArray(errors)) {
138
+ dErrors = errors.map((e) => (e instanceof ValidationError ? e : new ValidationError(e)));
139
+ }
140
+
141
+ deserialisedValidation[notProto(waypoint)] = dErrors;
142
+ }
143
+
144
+ return new JourneyContext(data, deserialisedValidation, nav, identity);
145
+ }
146
+
147
+ get data() {
148
+ return this.#data;
149
+ }
150
+
151
+ set data(value) {
152
+ this.#data = value;
153
+ }
154
+
155
+ get validation() {
156
+ return this.#validation;
157
+ }
158
+
159
+ get nav() {
160
+ return this.#nav;
161
+ }
162
+
163
+ get identity() {
164
+ return this.#identity;
165
+ }
166
+
167
+ /**
168
+ * Get data context for a specific a specific page.
169
+ *
170
+ * @param {string | Page} page Page waypoint ID, or Page object.
171
+ * @returns {object} Page data.
172
+ * @throws {TypeError} When page is invalid.
173
+ */
174
+ getDataForPage(page) {
175
+ if (typeof page === 'string') {
176
+ return this.#data[validateObjectKey(page)];
177
+ }
178
+ if (isPlainObject(page)) {
179
+ return this.#data[validateObjectKey(page.waypoint)];
180
+ }
181
+ throw new TypeError(`Page must be a string or Page object. Got ${typeof page}`);
182
+ }
183
+
184
+ /**
185
+ * Get all data.
186
+ *
187
+ * @returns {object} Page data
188
+ */
189
+ getData() {
190
+ return this.#data;
191
+ }
192
+
193
+ /**
194
+ * Overwrite the data context with a new object.
195
+ *
196
+ * @param {object} data Data that will overwrite all existing data.
197
+ * @returns {JourneyContext} Chain.
198
+ */
199
+ setData(data) {
200
+ this.#data = data;
201
+ return this;
202
+ }
203
+
204
+ /**
205
+ * Write field form data from a page HTML form, into the `data` model.
206
+ *
207
+ * @param {string | Page} page Page waypoint ID, or Page object
208
+ * @param {object} webFormData Data to overwrite with
209
+ * @returns {JourneyContext} Chain
210
+ * @throws {TypeError} When page is invalid.
211
+ */
212
+ setDataForPage(page, webFormData) {
213
+ if (typeof page === 'string') {
214
+ this.#data[validateObjectKey(page)] = webFormData;
215
+ } else if (isPlainObject(page)) {
216
+ this.#data[validateObjectKey(page.waypoint)] = webFormData;
217
+ } else {
218
+ throw new TypeError(`Page must be a string or Page object. Got ${typeof page}`)
219
+ }
220
+
221
+ return this;
222
+ }
223
+
224
+ /**
225
+ * Return validation errors for all pages.
226
+ *
227
+ * @returns {object} All page validation errors.
228
+ */
229
+ getValidationErrors() {
230
+ return this.#validation;
231
+ }
232
+
233
+ /**
234
+ * Removes any validation state for the given page. Clearing validation state
235
+ * completely will, by default, prevent onward traversal from this page. See
236
+ * the traversal logic in Plan class.
237
+ *
238
+ * @param {string} pageId Page ID.
239
+ * @returns {JourneyContext} Chain.
240
+ */
241
+ removeValidationStateForPage(pageId) {
242
+ const { [pageId]: dummy, ...remaining } = this.#validation;
243
+ this.#validation = { ...remaining };
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Clear any validation errors for the given page. This effectively declares
249
+ * that this page has been successfully validated, and so can be traversed. If
250
+ * you want to remove any knowledge of validation success/failure, use
251
+ * `removeValidationStateForPage()` instead.
252
+ *
253
+ * @param {string} pageId Page ID.
254
+ * @returns {JourneyContext} Chain.
255
+ */
256
+ clearValidationErrorsForPage(pageId) {
257
+ this.#validation[validateObjectKey(pageId)] = null;
258
+ return this;
259
+ }
260
+
261
+ /**
262
+ * Set validation errors for a page.
263
+ *
264
+ * @param {string} pageId Page ID.
265
+ * @param {ValidationError[]} errors Errors
266
+ * @returns {JourneyContext} Chain.
267
+ * @throws {SyntaxError} When errors are invalid.
268
+ */
269
+ setValidationErrorsForPage(pageId, errors = []) {
270
+ if (!Array.isArray(errors)) {
271
+ throw new SyntaxError(`Errors must be an Array. Received ${Object.prototype.toString.call(errors)}`);
272
+ }
273
+
274
+ errors.forEach((error) => {
275
+ if (!(error instanceof ValidationError)) {
276
+ throw new SyntaxError('Field errors must be a ValidationError');
277
+ }
278
+ });
279
+
280
+ this.#validation[validateObjectKey(pageId)] = errors;
281
+
282
+ return this;
283
+ }
284
+
285
+ /**
286
+ * Return the validation errors associated with the page's currently held data
287
+ * context (if any).
288
+ *
289
+ * @param {string} pageId Page ID.
290
+ * @returns {ValidationError[]} An array of errors
291
+ */
292
+ getValidationErrorsForPage(pageId) {
293
+ return this.#validation[validateObjectKey(pageId)] ?? [];
294
+ }
295
+
296
+ /**
297
+ * Same as `getValidationErrorsForPage()`, but the return value is
298
+ * an object whose keys are the field names, and values are the list of errors
299
+ * associated with that particular field.
300
+ *
301
+ * @param {string} pageId Page ID.
302
+ * @returns {object} Object indexed by field names; values containing list of errors
303
+ */
304
+ getValidationErrorsForPageByField(pageId) {
305
+ const errors = this.getValidationErrorsForPage(pageId);
306
+ const obj = Object.create(null);
307
+
308
+ // ESLint disabled as `i` is an integer
309
+ /* eslint-disable security/detect-object-injection */
310
+ for (let i = 0, l = errors.length; i < l; i++) {
311
+ if (!obj[errors[i].field]) {
312
+ obj[errors[i].field] = [];
313
+ }
314
+ obj[errors[i].field].push(errors[i]);
315
+ }
316
+ /* eslint-enable security/detect-object-injection */
317
+
318
+ return obj;
319
+ }
320
+
321
+ /**
322
+ * Determine whether the specified page has any errors in its validation
323
+ * context.
324
+ *
325
+ * @param {string} pageId Page ID.
326
+ * @returns {boolean} Result.
327
+ */
328
+ hasValidationErrorsForPage(pageId) {
329
+ return this.#validation?.[validateObjectKey(pageId)]?.length > 0;
330
+ }
331
+
332
+ /**
333
+ * Set language of the context.
334
+ *
335
+ * @param {string} language Language to set (ISO 639-1 2-letter code).
336
+ * @returns {JourneyContext} Chain.
337
+ */
338
+ setNavigationLanguage(language = 'en') {
339
+ this.#nav.language = language;
340
+ return this;
341
+ }
342
+
343
+ /**
344
+ * Convenience function to test if page is valid.
345
+ *
346
+ * @param {string} pageId Page ID.
347
+ * @returns {boolean} True if the page is valid.
348
+ */
349
+ isPageValid(pageId) {
350
+ return this.#validation[validateObjectKey(pageId)] === null;
351
+ }
352
+
353
+ /**
354
+ * Remove information about these waypoints.
355
+ *
356
+ * @param {string[]} waypoints Waypoints to be removed
357
+ */
358
+ purge(waypoints = []) {
359
+ const newData = Object.create(null);
360
+ const newValidation = Object.create(null);
361
+ const toKeep = Object.keys(this.data).filter((w) => !waypoints.includes(w));
362
+
363
+ // ESLint disabled as `i` is an integer
364
+ /* eslint-disable security/detect-object-injection */
365
+ for (let i = 0, l = toKeep.length; i < l; i++) {
366
+ newData[toKeep[i]] = this.#data[toKeep[i]];
367
+ newValidation[toKeep[i]] = this.#validation[toKeep[i]];
368
+ }
369
+ /* eslint-enable security/detect-object-injection */
370
+
371
+ this.#data = { ...newData };
372
+ this.#validation = { ...newValidation };
373
+ }
374
+
375
+ /**
376
+ * Remove validation state from these waypoints. This is useful to quickly
377
+ * force the user to revisit some waypoints.
378
+ *
379
+ * @param {string[]} waypoints Waypoints to be invalidated
380
+ * @returns {void}
381
+ */
382
+ invalidate(waypoints = []) {
383
+ for (let i = 0, l = waypoints.length; i < l; i++) {
384
+ // ESLint disabled as `i` is an integer
385
+ /* eslint-disable-next-line security/detect-object-injection */
386
+ this.removeValidationStateForPage(waypoints[i]);
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Event listeners are transient. They are not stored in session, and generally
392
+ * only apply for the current request.
393
+ *
394
+ * They also only act on a fixed snapshot of this context's state, which is
395
+ * taken at the point of attaching the listeners (in the "data" middleware).
396
+ * This is important because JourneyContext.putContext()` could be called many
397
+ * times during a request, so the context will be constantly changing.
398
+ *
399
+ * @param {ContextEvent[]} events Event listeners
400
+ * @returns {JourneyContext} Chain
401
+ */
402
+ addEventListeners(events) {
403
+ this.#eventListeners = events;
404
+ this.#eventListenerPreState = this.toObject();
405
+ return this;
406
+ }
407
+
408
+ /**
409
+ * Execute all listeners for the given event.
410
+ *
411
+ * @param {object} params Params
412
+ * @param {string} params.event Event (waypoint-change | context-change)
413
+ * @param {object} params.session Session
414
+ * @returns {JourneyContext} Chain
415
+ */
416
+ applyEventListeners({ event, session }) {
417
+ if (!this.#eventListeners.length) {
418
+ return this;
419
+ }
420
+
421
+ const previousContext = JourneyContext.fromObject(this.#eventListenerPreState);
422
+ const listeners = this.#eventListeners.filter((l) => l.event === event);
423
+
424
+ // ESLint disabled as `listeners[i]` uses an integer key, and the other keys
425
+ // are derived from the list of `listeners`, which are not manipulated at
426
+ // runtime (only set by dev in code).
427
+ /* eslint-disable security/detect-object-injection */
428
+ for (let i = 0, l = listeners.length; i < l; i++) {
429
+ const { waypoint, field, handler } = listeners[i];
430
+
431
+ let logMessage;
432
+ let runHandler = false;
433
+
434
+ if (!waypoint && !field) {
435
+ logMessage = 'Calling generic event handler';
436
+ runHandler = true;
437
+ } else if (waypoint && !field) {
438
+ logMessage = `Calling waypoint-specific event handler on "${waypoint}"`;
439
+ runHandler = previousContext.data?.[waypoint] !== undefined && !isEqual(
440
+ this.data?.[waypoint],
441
+ previousContext.data?.[waypoint],
442
+ );
443
+ } else if (waypoint && field) {
444
+ logMessage = `Calling field-specific event handler on "${waypoint} : ${field}"`;
445
+ runHandler = previousContext.data?.[waypoint]?.[field] !== undefined && !isEqual(
446
+ this.data?.[waypoint]?.[field],
447
+ previousContext.data?.[waypoint]?.[field],
448
+ );
449
+ }
450
+
451
+ if (runHandler) {
452
+ log.trace(logMessage);
453
+ handler({ journeyContext: this, previousContext, session });
454
+ }
455
+ }
456
+ /* eslint-enable security/detect-object-injection */
457
+
458
+ return this;
459
+ }
460
+
461
+ /* ----------------------------------------------- session context handling */
462
+
463
+ /**
464
+ * Construct a new ephemeral JourneyContext instance with a unique ID.
465
+ *
466
+ * @returns {JourneyContext} Constructed JourneyContext instance
467
+ */
468
+ static createEphemeralContext() {
469
+ return JourneyContext.fromObject({
470
+ identity: {
471
+ id: uuidv4(),
472
+ },
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Construct a new JourneyContext instance from another instance.
478
+ *
479
+ * @param {JourneyContext} context Context to copy from
480
+ * @returns {JourneyContext} Constructed JourneyContext instance
481
+ * @throws {TypeError} When context is not a valid type
482
+ */
483
+ static fromContext(context) {
484
+ if (!(context instanceof JourneyContext)) {
485
+ throw new TypeError('Source context must be a JourneyContext');
486
+ }
487
+
488
+ const newContextObj = context.toObject();
489
+ newContextObj.identity.id = uuidv4();
490
+
491
+ return JourneyContext.fromObject(newContextObj);
492
+ }
493
+
494
+ /**
495
+ * Convenience method to determine if this is the default context.
496
+ *
497
+ * @returns {boolean} True if this is the "default" journey context
498
+ */
499
+ isDefault() {
500
+ return this.#identity.id === JourneyContext.DEFAULT_CONTEXT_ID;
501
+ }
502
+
503
+ /**
504
+ * Initialise session with an empty entry for the "default" context.
505
+ *
506
+ * @param {object} session Request session
507
+ * @returns {void}
508
+ */
509
+ static initContextStore(session) {
510
+ // For existing sessions that were created prior to `journeyContextList`
511
+ // being remodelled as an array, we need to convert the "legacy" structure
512
+ // into an equivalent array.
513
+ if (isPlainObject(session?.journeyContextList)) {
514
+ log.trace('Session context list already initialised as an object (legacy structure). Will convert from object to array.');
515
+ /* eslint-disable-next-line no-param-reassign */
516
+ session.journeyContextList = Object.entries(session.journeyContextList);
517
+ }
518
+
519
+ // Initialise new context list in the session
520
+ if (!has(session, 'journeyContextList')) {
521
+ log.trace('Initialising session with a default journey context list');
522
+ /* eslint-disable-next-line no-param-reassign */
523
+ session.journeyContextList = [];
524
+
525
+ const defaultContext = new JourneyContext();
526
+ defaultContext.identity.id = JourneyContext.DEFAULT_CONTEXT_ID;
527
+ JourneyContext.putContext(session, defaultContext);
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Validate the format of a context ID, i.e. "default" or a uuid
533
+ * eg 00000000-0000-0000-0000-000000000000
534
+ * eg 123e4567-e89b-12d3-a456-426614174000
535
+ *
536
+ * @param {string} id Context ID
537
+ * @returns {string} Original ID if it's valid
538
+ * @throws {TypeError} When id is not a valid type
539
+ * @throws {SyntaxError} When id is not a valid uuid format
540
+ */
541
+ static validateContextId(id) {
542
+ if (id === JourneyContext.DEFAULT_CONTEXT_ID) {
543
+ return JourneyContext.DEFAULT_CONTEXT_ID;
544
+ }
545
+
546
+ if (typeof id !== 'string') {
547
+ throw new TypeError('Context ID must be a string');
548
+ } else if (!uuidValidate(id)) {
549
+ throw new SyntaxError('Context ID is not in the correct uuid format');
550
+ }
551
+
552
+ return id;
553
+ }
554
+
555
+ /**
556
+ * Retrieve the default Journey Context. This is just a convenient wrapper
557
+ * around `getContextById()`.
558
+ *
559
+ * @param {object} session Request session
560
+ * @returns {JourneyContext} The default Journey Context
561
+ */
562
+ static getDefaultContext(session) {
563
+ return JourneyContext.getContextById(session, JourneyContext.DEFAULT_CONTEXT_ID);
564
+ }
565
+
566
+ /**
567
+ * Lookup context from session using the ID.
568
+ *
569
+ * @param {object} session Request session
570
+ * @param {string} id Context ID
571
+ * @returns {JourneyContext} The discovered JourneyContext instance
572
+ */
573
+ static getContextById(session, id) {
574
+ const list = new Map(session?.journeyContextList);
575
+ if (list.has(id)) {
576
+ // ESLint disabled as `id` has been verified as an "own" property
577
+ /* eslint-disable-next-line security/detect-object-injection */
578
+ return JourneyContext.fromObject(list.get(id));
579
+ }
580
+
581
+ return undefined;
582
+ }
583
+
584
+ /**
585
+ * Lookup context from session using the name.
586
+ *
587
+ * @param {object} session Request session
588
+ * @param {string} name Context name
589
+ * @returns {JourneyContext} The discovered JourneyContext instance
590
+ */
591
+ static getContextByName(session, name) {
592
+ if (session) {
593
+ const list = new Map(session?.journeyContextList);
594
+ const context = [...list.values()].find(
595
+ (c) => (c.identity.name === name),
596
+ );
597
+ if (context) {
598
+ return JourneyContext.fromObject(context);
599
+ }
600
+ }
601
+
602
+ return undefined;
603
+ }
604
+
605
+ /**
606
+ * Lookup contexts from session using the tag.
607
+ *
608
+ * @param {object} session Request session
609
+ * @param {string} tag Context tag
610
+ * @returns {Array<JourneyContext>} The discovered JourneyContext instance
611
+ */
612
+ static getContextsByTag(session, tag) {
613
+ if (session) {
614
+ const list = new Map(session?.journeyContextList);
615
+ return [...list.values()].filter(
616
+ (c) => (c.identity.tags?.includes(tag)),
617
+ ).map((c) => (JourneyContext.fromObject(c)));
618
+ }
619
+
620
+ return undefined;
621
+ }
622
+
623
+ /**
624
+ * Return all contexts currently stored in the session.
625
+ *
626
+ * @param {object} session Request session
627
+ * @returns {Array} Array of contexts
628
+ */
629
+ static getContexts(session) {
630
+ if (has(session, 'journeyContextList')) {
631
+ return session.journeyContextList.map(([, contextObj]) => (
632
+ JourneyContext.fromObject(contextObj)
633
+ ));
634
+ }
635
+
636
+ return [];
637
+ }
638
+
639
+ /**
640
+ * Put context back into the session store.
641
+ *
642
+ * @param {object} session Request session
643
+ * @param {JourneyContext} context Context
644
+ * @returns {void}
645
+ * @throws {TypeError} When session is not a valid type, or context has no ID
646
+ */
647
+ static putContext(session, context) {
648
+ if (!isObject(session)) {
649
+ throw new TypeError('Session must be an object');
650
+ } else if (!(context instanceof JourneyContext)) {
651
+ throw new TypeError('Context must be a valid JourneyContext');
652
+ } else if (context.identity.id === undefined) {
653
+ throw new TypeError('Context must have an ID before storing in session');
654
+ }
655
+
656
+ // Initialise the session if necessary
657
+ if (!has(session, 'journeyContextList')) {
658
+ JourneyContext.initContextStore(session);
659
+ }
660
+
661
+ // Apply context events
662
+ context.applyEventListeners({
663
+ event: 'waypoint-change',
664
+ session,
665
+ });
666
+
667
+ context.applyEventListeners({
668
+ event: 'context-change',
669
+ session,
670
+ });
671
+
672
+ const list = new Map(session.journeyContextList);
673
+ list.set(context.identity.id, context.toObject());
674
+ /* eslint-disable-next-line no-param-reassign */
675
+ session.journeyContextList = [...list.entries()];
676
+ }
677
+
678
+ /**
679
+ * Remove a context from the session store.
680
+ *
681
+ * @param {object} session Request session
682
+ * @param {JourneyContext} context Context
683
+ * @returns {void}
684
+ */
685
+ static removeContext(session, context) {
686
+ if (context instanceof JourneyContext) {
687
+ JourneyContext.removeContextById(session, context.identity.id);
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Remove context from session using the ID.
693
+ *
694
+ * @param {object} session Request session
695
+ * @param {string} id Context ID
696
+ * @returns {void}
697
+ */
698
+ static removeContextById(session, id) {
699
+ const index = (session?.journeyContextList ?? []).findIndex(([contextId]) => contextId === id);
700
+ if (index > -1) {
701
+ session.journeyContextList.splice(index, 1);
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Remove context from session using the name.
707
+ *
708
+ * @param {object} session Request session
709
+ * @param {string} name Context name
710
+ * @returns {void}
711
+ */
712
+ static removeContextByName(session, name) {
713
+ JourneyContext.removeContext(
714
+ session,
715
+ JourneyContext.getContextByName(session, name),
716
+ );
717
+ }
718
+
719
+ /**
720
+ * Remove context from session using the tag.
721
+ *
722
+ * @param {object} session Request session
723
+ * @param {string} tag Context tag
724
+ * @returns {void}
725
+ */
726
+ static removeContextsByTag(session, tag) {
727
+ JourneyContext.getContextsByTag(session, tag).forEach(
728
+ (c) => JourneyContext.removeContext(session, c),
729
+ );
730
+ }
731
+
732
+ /**
733
+ * Remove call contexts.
734
+ *
735
+ * @param {object} session Request session
736
+ * @returns {void}
737
+ */
738
+ static removeContexts(session) {
739
+ JourneyContext.getContexts(session).forEach((c) => JourneyContext.removeContext(session, c));
740
+ }
741
+
742
+ /**
743
+ * Extract the Journey Context referred to in the incoming request.
744
+ *
745
+ * This will look in `req.params`, `req.query` and
746
+ * `req.body` for a `contextid` parameter, and use that
747
+ * to load the correct Journey Context from the session.
748
+ *
749
+ * @param {ExpressRequest} req ExpressJS incoming request
750
+ * @returns {JourneyContext} The Journey Context
751
+ */
752
+ static extractContextFromRequest(req) {
753
+ JourneyContext.initContextStore(req.session);
754
+
755
+ let contextId;
756
+ if (has(req?.params, 'contextid')) {
757
+ log.trace('Context ID found in req.params.contextid');
758
+ contextId = String(req.params.contextid);
759
+ } else if (has(req.query, 'contextid')) {
760
+ log.trace('Context ID found in req.query.contextid');
761
+ contextId = String(req.query.contextid);
762
+ } else if (has(req?.body, 'contextid')) {
763
+ log.trace('Context ID found in req.body.contextid');
764
+ contextId = String(req.body.contextid);
765
+ } else {
766
+ log.trace('Context ID not specified or not found; will attempt to use default');
767
+ contextId = JourneyContext.DEFAULT_CONTEXT_ID;
768
+ }
769
+
770
+ try {
771
+ contextId = JourneyContext.validateContextId(contextId);
772
+ const context = JourneyContext.getContextById(req.session, contextId);
773
+ if (!context) {
774
+ throw (new Error(`Could not find a context with id, ${contextId}`));
775
+ }
776
+ return context;
777
+ } catch (err) {
778
+ log.debug(err.message);
779
+ log.trace('Falling back to default context');
780
+ return JourneyContext.getContextById(req.session, JourneyContext.DEFAULT_CONTEXT_ID);
781
+ }
782
+ }
783
+ }