@gov-cy/govcy-express-services 1.8.2 → 1.9.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.
@@ -1,4 +1,12 @@
1
- import * as govcyResources from "../resources/govcyResources.mjs";
1
+ import * as govcyResources from "../resources/govcyResources.mjs";
2
+
3
+ const ALLOWED_TASK_STATUSES = new Set(["NOT_STARTED", "IN_PROGRESS", "COMPLETED"]);
4
+
5
+ function normalizeTaskStatus(statusKey = "NOT_STARTED") {
6
+ if (typeof statusKey !== "string") return "NOT_STARTED";
7
+ const normalized = statusKey.toUpperCase();
8
+ return ALLOWED_TASK_STATUSES.has(normalized) ? normalized : "NOT_STARTED";
9
+ }
2
10
 
3
11
  /**
4
12
  * Defines custom pages for a given siteId and pageUrl.
@@ -9,21 +17,23 @@ import * as govcyResources from "../resources/govcyResources.mjs";
9
17
  * @param {string} pageTitle The title of the custom page (e.g., `{ en: "My custom section", el: "Προσαρμοσμένη ενότητα" }`)
10
18
  * @param {string} insertAfterPageUrl The page URL to insert the custom page after (e.g., `"qualifications"`)
11
19
  * @param {array} errors An array of error objects (e.g., `[{ id: "error1", text: { en: "Error 1", el: "Error 1"} }, { id: "error2", text: { en: "Error 2", el: "Error 2"} }]`)
12
- * @param {array} summaryElements An array of summary element objects (e.g., `{ key: { en: "Extra value", el: "Πρόσθετη τιμή" }, value: [] }` )
13
- * @param {boolean} summaryHtml Optional HTML content for the summary (e.g., `{ en: "<strong>Summary HTML</strong>", el: "<strong>Περίληψη HTML</strong>" }`)
14
- * @param {object} [extraProps={}] Optional extra metadata (e.g., `{ nextPage: "confirmation", requiresOTP: true }`)
15
- */
16
- export function defineCustomPages(
17
- store,
18
- siteId,
20
+ * @param {array} summaryElements An array of summary element objects (e.g., `{ key: { en: "Extra value", el: "Πρόσθετη τιμή" }, value: [] }` )
21
+ * @param {boolean} summaryHtml Optional HTML content for the summary (e.g., `{ en: "<strong>Summary HTML</strong>", el: "<strong>Περίληψη HTML</strong>" }`)
22
+ * @param {string} taskStatus Optional default task status for task lists (NOT_STARTED/IN_PROGRESS/COMPLETED)
23
+ * @param {object} [extraProps={}] Optional extra metadata (e.g., `{ nextPage: "confirmation", requiresOTP: true }`)
24
+ */
25
+ export function defineCustomPages(
26
+ store,
27
+ siteId,
19
28
  pageUrl,
20
29
  pageTitle,
21
30
  insertAfterPageUrl,
22
31
  errors,
23
- summaryElements,
24
- summaryHtml = false,
25
- extraProps = {}
26
- ) {
32
+ summaryElements,
33
+ summaryHtml = false,
34
+ taskStatus = "NOT_STARTED",
35
+ extraProps = {}
36
+ ) {
27
37
  // Initialize custom pages in session if not already set
28
38
  store.siteData ??= {};
29
39
  store.siteData[siteId] ??= {};
@@ -44,15 +54,16 @@ export function defineCustomPages(
44
54
  let summaryActions = [];
45
55
 
46
56
  // Base definition
47
- const definition = {
48
- pageTitle,
49
- insertAfterPageUrl,
50
- summaryElements: summaryElements || [],
51
- errors: normalizedErrors,
52
- summaryActions: summaryActions,
53
- ...(summaryHtml ? { summaryHtml } : {}),
54
- ...extraProps // Merge any additional developer-defined properties
55
- };
57
+ const definition = {
58
+ pageTitle,
59
+ insertAfterPageUrl,
60
+ summaryElements: summaryElements || [],
61
+ errors: normalizedErrors,
62
+ summaryActions: summaryActions,
63
+ taskStatus: normalizeTaskStatus(taskStatus),
64
+ ...(summaryHtml ? { summaryHtml } : {}),
65
+ ...extraProps // ✅ Merge any additional developer-defined properties
66
+ };
56
67
 
57
68
  // Construct default summaryActions
58
69
  if (pageTitle && pageUrl) {
@@ -234,7 +245,7 @@ export function clearCustomPageErrors(store, siteId, pageUrl) {
234
245
  * @param {string} errorId - Unique identifier for the error.
235
246
  * @param {Object} errorTextObject - Multilingual Object containing localized error texts.
236
247
  */
237
- export function addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject) {
248
+ export function addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject) {
238
249
  const normalizedPageUrl = pageUrl.replace(/^\/+/, "");
239
250
 
240
251
  const target =
@@ -257,4 +268,35 @@ export function addCustomPageError(store, siteId, pageUrl, errorId, errorTextObj
257
268
  pageUrl: normalizedPageUrl,
258
269
  });
259
270
 
260
- }
271
+ }
272
+
273
+ /**
274
+ * Sets the task status for a custom page so task-list pages can show progress.
275
+ *
276
+ * @param {object} store Session store (req.session)
277
+ * @param {string} siteId Current site id
278
+ * @param {string} pageUrl Custom page url
279
+ * @param {string} statusKey One of NOT_STARTED/IN_PROGRESS/COMPLETED
280
+ */
281
+ export function setCustomPageTaskStatus(store, siteId, pageUrl, statusKey = "NOT_STARTED") {
282
+ const target = store?.siteData?.[siteId]?.customPages?.[pageUrl];
283
+ if (!target) {
284
+ console.warn(`⚠️ setCustomPageTaskStatus: page '${pageUrl}' not found for site '${siteId}'`);
285
+ return;
286
+ }
287
+ target.taskStatus = normalizeTaskStatus(statusKey);
288
+ }
289
+
290
+ /**
291
+ * Retrieves the stored task status for a custom page.
292
+ *
293
+ * @param {object} store Session store (req.session)
294
+ * @param {string} siteId Current site id
295
+ * @param {string} pageUrl Custom page url
296
+ * @returns {string} Task status key
297
+ */
298
+ export function getCustomPageTaskStatus(store, siteId, pageUrl) {
299
+ const target = store?.siteData?.[siteId]?.customPages?.[pageUrl];
300
+ if (!target) return "NOT_STARTED";
301
+ return normalizeTaskStatus(target.taskStatus);
302
+ }
@@ -106,18 +106,43 @@ export function storePageValidationErrors(store, siteId, pageUrl, validationErro
106
106
  * @param {string} pageUrl The page url
107
107
  * @param {object} formData The form data to be stored
108
108
  */
109
- export function storePageData(store, siteId, pageUrl, formData) {
110
- // Ensure session structure is initialized
111
- initializeSiteData(store, siteId, pageUrl);
112
-
113
- store.siteData[siteId].inputData[pageUrl]["formData"] = formData;
114
- }
115
-
116
- export function storePageDataElement(store, siteId, pageUrl, elementName, value) {
117
- // Ensure session structure is initialized
118
- initializeSiteData(store, siteId, pageUrl);
119
-
120
- // Store the element value
109
+ export function storePageData(store, siteId, pageUrl, formData) {
110
+ // Ensure session structure is initialized
111
+ initializeSiteData(store, siteId, pageUrl);
112
+
113
+ store.siteData[siteId].inputData[pageUrl]["formData"] = formData;
114
+ }
115
+
116
+ /**
117
+ * Marks whether a page has been posted/submitted at least once.
118
+ *
119
+ * @param {object} store Session store (req.session)
120
+ * @param {string} siteId Site id
121
+ * @param {string} pageUrl Page url
122
+ * @param {boolean} posted Flag indicating post action (defaults to true)
123
+ */
124
+ export function setPagePosted(store, siteId, pageUrl, posted = true) {
125
+ initializeSiteData(store, siteId, pageUrl);
126
+ store.siteData[siteId].inputData[pageUrl]["posted"] = Boolean(posted);
127
+ }
128
+
129
+ /**
130
+ * Returns true if the user has already posted/submitted the page.
131
+ *
132
+ * @param {object} store Session store (req.session)
133
+ * @param {string} siteId Site id
134
+ * @param {string} pageUrl Page url
135
+ * @returns {boolean}
136
+ */
137
+ export function isPagePosted(store, siteId, pageUrl) {
138
+ return Boolean(store?.siteData?.[siteId]?.inputData?.[pageUrl]?.posted);
139
+ }
140
+
141
+ export function storePageDataElement(store, siteId, pageUrl, elementName, value) {
142
+ // Ensure session structure is initialized
143
+ initializeSiteData(store, siteId, pageUrl);
144
+
145
+ // Store the element value
121
146
  store.siteData[siteId].inputData[pageUrl].formData[elementName] = value;
122
147
  }
123
148
  /**
@@ -42,6 +42,11 @@ export function prepareSubmissionData(req, siteId, service) {
42
42
 
43
43
  // Loop through every page in the service definition
44
44
  for (const page of service.pages) {
45
+
46
+ // ----- taskList handling
47
+ if (page.taskList) {
48
+ continue; // Task list pages do not contribute submission payloads
49
+ }
45
50
  const pageUrl = page.pageData.url || "";
46
51
 
47
52
  // Find the <form> element in the page
@@ -347,12 +352,16 @@ export function preparePrintFriendlyData(req, siteId, service) {
347
352
  if (conditionResult.result === false) {
348
353
  continue; // ⛔ Skip this page from print-friendly data
349
354
  }
355
+ // ----- taskList handling
356
+ if (page.taskList) {
357
+ continue; // Task list hub is a navigation shell, no print-friendly entry
358
+ }
350
359
 
351
360
  let pageTemplate = page.pageTemplate;
352
361
  let pageTitle = page.pageData.title || {};
353
362
 
354
363
 
355
- // ----- MultipleThings hub handling
364
+ // ----- updateMyDetails hub handling
356
365
  if (page.updateMyDetails) {
357
366
  // create the page template
358
367
  pageTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
@@ -0,0 +1,284 @@
1
+ import { getPageConfigData } from "./govcyLoadConfigData.mjs";
2
+ import { evaluatePageConditions } from "./govcyExpressions.mjs";
3
+ import { validateFormElements } from "./govcyValidator.mjs";
4
+ import { validateMultipleThings } from "./govcyMultipleThingsValidation.mjs";
5
+ import { createUmdManualPageTemplate } from "../middleware/govcyUpdateMyDetails.mjs";
6
+ import * as dataLayer from "./govcyDataLayer.mjs";
7
+ import { logger } from "./govcyLogger.mjs";
8
+ import * as govcyResources from "../resources/govcyResources.mjs";
9
+ import { getCustomPageTaskStatus } from "./govcyCustomPages.mjs";
10
+
11
+ /**
12
+ * Computes the completion status for a single page.
13
+ * Mirrors review-stage validation so task lists use the same rules.
14
+ */
15
+ export function computePageTaskStatus(req, siteId, service, pageUrl, visitedPages = new Set()) {
16
+ if (!req || !req.session) {
17
+ logger.error("computePageTaskStatus called without session", { siteId, pageUrl });
18
+ throw new Error("computePageTaskStatus: request with session is required");
19
+ }
20
+ if (!service || !Array.isArray(service.pages)) {
21
+ logger.error("computePageTaskStatus service missing pages array", { siteId, pageUrl });
22
+ throw new Error("computePageTaskStatus: service.pages is required");
23
+ }
24
+ if (!pageUrl) {
25
+ logger.error("computePageTaskStatus missing pageUrl", { siteId });
26
+ throw new Error("computePageTaskStatus: pageUrl is required");
27
+ }
28
+
29
+ // Handle custom pages first
30
+ const customPage = dataLayer.getSiteCustomPages(req.session, siteId)?.[pageUrl];
31
+ if (customPage) {
32
+ return {
33
+ pageUrl,
34
+ title: customPage?.pageTitle || {},
35
+ type: "custom",
36
+ status: getCustomPageTaskStatus(req.session, siteId, pageUrl),
37
+ hasData: Boolean(customPage?.data),
38
+ custom: true
39
+ };
40
+ }
41
+
42
+ const page = getPageConfigData(service, pageUrl);
43
+ if (!page) {
44
+ logger.error("computePageTaskStatus page not found", { siteId, pageUrl });
45
+ throw new Error("computePageTaskStatus: page '" + pageUrl + "' not found in service configuration");
46
+ }
47
+
48
+ // Use a composite key so nested task-list evaluation can detect circular references.
49
+ const cycleKey = siteId + ":" + pageUrl;
50
+ if (visitedPages.has(cycleKey)) {
51
+ const cyclePath = [...visitedPages, cycleKey].map(key => key.split(":")[1]);
52
+ logger.error("computePageTaskStatus circular task list reference detected", {
53
+ siteId,
54
+ cycle: cyclePath
55
+ });
56
+ throw new Error("Task list configuration contains a circular reference: " + cyclePath.join(" -> "));
57
+ }
58
+
59
+ visitedPages.add(cycleKey);
60
+
61
+ try {
62
+ // Reuse the same condition logic as page rendering – if a page would redirect,
63
+ // we treat it as skipped so task lists never block on hidden sections.
64
+ const conditionResult = evaluatePageConditions(page, req.session, siteId, req);
65
+ if (conditionResult?.result === false) {
66
+ return buildStatusResult(page, determinePageType(page), pageUrl, false, "SKIPPED", conditionResult);
67
+ }
68
+
69
+ const pageType = determinePageType(page);
70
+ if (pageType === "taskList") {
71
+ // For task list pages, recurse into each configured children and aggregate their status.
72
+ const taskPages = Array.isArray(page?.taskList?.taskPages) ? page.taskList.taskPages : [];
73
+ const summary = computeTaskListStatus(req, siteId, service, taskPages, visitedPages);
74
+ const result = buildStatusResult(page, pageType, pageUrl, summary.status !== "NOT_STARTED", summary.status);
75
+ result.taskList = summary;
76
+ return result;
77
+ }
78
+
79
+ // Below covers all data-entry pages (normal, multipleThings, updateMyDetails)
80
+ // For form-based pages, inspect the session data and validation results.
81
+ let storedData = dataLayer.getPageData(req.session, siteId, pageUrl);
82
+ const postedFlag = dataLayer.isPagePosted(req.session, siteId, pageUrl);
83
+
84
+ // Multiple things hubs always expect an array, so normalize bad shapes.
85
+ if (pageType === "multipleThings" && !Array.isArray(storedData)) {
86
+ storedData = [];
87
+ }
88
+ // All other page types expect an object. When nothing is stored yet, fall back to {}.
89
+ if (!storedData || typeof storedData !== "object") {
90
+ storedData = Array.isArray(storedData) ? storedData : {};
91
+ }
92
+
93
+ const hasData = determineHasData(storedData, pageType, postedFlag);
94
+ if (!hasData) {
95
+ return buildStatusResult(page, pageType, pageUrl, false, "NOT_STARTED");
96
+ }
97
+
98
+ // For form-based pages, inspect the session data and validation results.
99
+ const hasErrors = hasValidationErrors({
100
+ page,
101
+ pageType,
102
+ storedData,
103
+ req,
104
+ service,
105
+ siteId
106
+ });
107
+
108
+ return buildStatusResult(
109
+ page,
110
+ pageType,
111
+ pageUrl,
112
+ true,
113
+ hasErrors ? "IN_PROGRESS" : "COMPLETED"
114
+ );
115
+ } finally {
116
+ visitedPages.delete(cycleKey);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Computes statuses for all pages listed in taskPages and derives overall status.
122
+ */
123
+ export function computeTaskListStatus(req, siteId, service, taskPages = [], visitedPages = new Set()) {
124
+ if (!Array.isArray(taskPages)) {
125
+ logger.error("computeTaskListStatus expects taskPages array", { siteId });
126
+ throw new Error("taskPages must be an array");
127
+ }
128
+
129
+ const tasks = taskPages.map(pageUrl => computePageTaskStatus(req, siteId, service, pageUrl, visitedPages));
130
+ const status = deriveTaskListStatus(tasks);
131
+ return { status, tasks };
132
+ }
133
+
134
+ /**
135
+ * Returns a normalized type string so callers can branch per page category.
136
+ *
137
+ * @param {object} page Page configuration object
138
+ * @returns {"taskList"|"updateMyDetails"|"multipleThings"|"normal"}
139
+ */
140
+ function determinePageType(page) {
141
+ if (page?.taskList) return "taskList";
142
+ if (page?.updateMyDetails) return "updateMyDetails";
143
+ if (page?.multipleThings) return "multipleThings";
144
+ return "normal";
145
+ }
146
+
147
+ /**
148
+ * Determines whether the stored data should count as "started".
149
+ *
150
+ * @param {object|Array} storedData Data retrieved from the session
151
+ * @param {string} pageType Derived page type
152
+ * @param {boolean} postedFlag For multipleThings pages, whether user pressed continue at least once
153
+ * @returns {boolean}
154
+ */
155
+ function determineHasData(storedData, pageType, postedFlag = false) {
156
+
157
+ if (Array.isArray(storedData)) {
158
+ if (storedData.length > 0) return true;
159
+ return pageType === "multipleThings" && postedFlag;
160
+ }
161
+ if (storedData && typeof storedData === "object") {
162
+ const hasKeys = Object.keys(storedData).length > 0;
163
+ if (hasKeys) return true;
164
+ }
165
+ if (pageType === "multipleThings" && postedFlag) {
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Mirrors review validation to decide if a page currently has outstanding errors.
173
+ *
174
+ * @param {object} opts Inputs bundle
175
+ * @returns {boolean}
176
+ */
177
+ function hasValidationErrors({ page, pageType, storedData, req, service, siteId }) {
178
+ if (pageType === "multipleThings") {
179
+ const lang = service?.site?.lang || req?.globalLang || "el";
180
+ const errors = validateMultipleThings(page, storedData, lang);
181
+ return Object.keys(errors || {}).length > 0;
182
+ }
183
+
184
+ const formElement = getFormElementForPage({ page, pageType, req, service, siteId });
185
+ if (!formElement) {
186
+ return false;
187
+ }
188
+
189
+ const errors = validateFormElements(
190
+ formElement.params?.elements || [],
191
+ storedData,
192
+ page?.pageData?.url
193
+ );
194
+
195
+ return Object.keys(errors || {}).length > 0;
196
+ }
197
+
198
+ /**
199
+ * Retrieves a form element definition so the validator can run against it.
200
+ *
201
+ * @param {object} params Inputs bundle
202
+ * @returns {object|null}
203
+ */
204
+ function getFormElementForPage({ page, pageType, req, service, siteId }) {
205
+ if (pageType === "updateMyDetails") {
206
+ const lang = service?.site?.lang || req?.globalLang || "el";
207
+ const template = createUmdManualPageTemplate(siteId, lang, page, req, true);
208
+ return findFormElement(template?.sections);
209
+ }
210
+
211
+ return findFormElement(page?.pageTemplate?.sections);
212
+ }
213
+
214
+ /**
215
+ * Depth-first scan for the first form element within renderer sections.
216
+ *
217
+ * @param {Array} sections Renderer sections array
218
+ * @returns {object|null}
219
+ */
220
+ function findFormElement(sections = []) {
221
+ if (!Array.isArray(sections)) return null;
222
+ for (const section of sections) {
223
+ const elements = section?.elements || [];
224
+ for (const element of elements) {
225
+ if (element?.element === "form") {
226
+ return element;
227
+ }
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * Constructs the canonical payload returned by computePageTaskStatus.
235
+ *
236
+ * @param {object} page Page config
237
+ * @param {string} pageType Derived type string
238
+ * @param {string} pageUrl Page identifier
239
+ * @param {boolean} hasData Whether any user input exists
240
+ * @param {string} status Status flag
241
+ * @param {object|null} conditionResult Optional condition evaluation details
242
+ * @returns {object}
243
+ */
244
+ function buildStatusResult(page, pageType, pageUrl, hasData, status, conditionResult = null) {
245
+ const result = {
246
+ pageUrl,
247
+ title: pageType === "updateMyDetails"
248
+ ? govcyResources.staticResources.text.updateMyDetailsTitle
249
+ : (page?.pageData?.title || {}),
250
+ type: pageType,
251
+ status,
252
+ hasData
253
+ };
254
+
255
+ if (conditionResult) {
256
+ result.conditionResult = conditionResult;
257
+ }
258
+
259
+ return result;
260
+ }
261
+
262
+ /**
263
+ * Collapses individual task statuses into a single overall status.
264
+ *
265
+ * @param {Array<{status:string}>} tasks Task descriptors
266
+ * @returns {"NOT_STARTED"|"IN_PROGRESS"|"COMPLETED"}
267
+ */
268
+ function deriveTaskListStatus(tasks = []) {
269
+ const relevant = tasks.filter(task => task.status !== "SKIPPED");
270
+ if (relevant.length === 0) return "COMPLETED";
271
+
272
+ const statuses = relevant.map(task => task.status);
273
+ const hasNotStarted = statuses.includes("NOT_STARTED");
274
+ const hasCompleted = statuses.includes("COMPLETED");
275
+ const hasInProgress = statuses.includes("IN_PROGRESS");
276
+
277
+ if (hasInProgress || (hasCompleted && hasNotStarted)) {
278
+ return "IN_PROGRESS";
279
+ }
280
+ if (hasNotStarted) {
281
+ return "NOT_STARTED";
282
+ }
283
+ return "COMPLETED";
284
+ }