@gov-cy/govcy-express-services 1.3.1 → 1.4.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.
package/README.md CHANGED
@@ -43,6 +43,7 @@ The APIs used for submission, temporary save and file uploads are not part of th
43
43
  - [🔀 Conditional logic](#-conditional-logic)
44
44
  - [💾 Temporary save feature](#-temporary-save-feature)
45
45
  - [🗃️ Files uploads feature](#%EF%B8%8F-files-uploads-feature)
46
+ - [✨ Custom pages feature](#-custom-pages-feature)
46
47
  - [🛣️ Routes](#%EF%B8%8F-routes)
47
48
  - [👨‍💻 Environment variables](#-environment-variables)
48
49
  - [🔒 Security note](#-security-note)
@@ -2603,6 +2604,228 @@ To help back-end systems recognize the field as a file, the field's element name
2603
2604
  If these endpoints are not defined in the service JSON, the file upload logic is skipped entirely.
2604
2605
  Existing services will continue to work without modification.
2605
2606
 
2607
+ ### ✨ Custom pages feature
2608
+ The **Custom pages** feature allows developers to add service-specific custom pages that exist outside the standard Express Services JSON configuration.
2609
+
2610
+ These pages can collect data, display conditional content, and inject custom sections into the **review** and **email** stages of the service flow.
2611
+
2612
+ #### What do custom pages get out of the framework
2613
+ With the custom pages feature, developers can define **pages** and **routes** on an express **service**. On these pages the following apply out of the box:
2614
+ - Login requirement
2615
+ - User policy requirement
2616
+ - Any service eligibility checks
2617
+ - Csrf protection on POST
2618
+ - Generic error page on errors
2619
+
2620
+ The developers can also choose to:
2621
+ - store values in the session to be submitted via the submission API
2622
+ - define where and what the users sees in the `review` page
2623
+ - define errors regarding the custom page in the `review` page
2624
+ - define what the user receives the `email`
2625
+
2626
+ #### What the framework expects from custom pages
2627
+
2628
+ - Every custom page **must be defined** at startup using `defineCustomPages(app, siteId, pageUrl, ...)`.
2629
+ - Custom pages are **not** automatically discovered, they must be registered explicitly.
2630
+ - Each page must specify on **start up**:
2631
+ - `pageTitle`: multilingual object for display in review and email
2632
+ - `insertAfterPageUrl`: the page **after which** this custom page will appear
2633
+ - `summaryElements`, `errors`, and `email` are managed dynamically in session
2634
+ - **NOTE**: Usually a default `required` error must be defined on start up. This will ensure that the when the users try to submit in the “Check your answers” review page, they get an error message to complete an action.
2635
+ - `extraProps` extra property stored in the session to be used by the developers as they wish
2636
+ - During **runtime**, developers are responsible for setting:
2637
+ - `data`: data to include in submission, using the `setCustomPageData` function
2638
+ - `summaryElements`: what appears in the “Check your answers” review page, using the `setCustomPageSummaryElements` function
2639
+ - `email`: array of [`dsf-email-templates`](https://github.com/gov-cy/dsf-email-templates) components for the submission confirmation email, using the `setCustomPageEmail`
2640
+ - `errors`: errors that appear in the “Check your answers” review page. Developers can use the `addCustomPageError` function to add an error, or the `clearCustomPageErrors` to clear the errors
2641
+ #### Available methods
2642
+
2643
+ | Method | Purpose |
2644
+ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
2645
+ | `defineCustomPages(store, siteId, pageUrl, pageTitle, insertAfterSummary, insertAfterPageUrl, errors, summaryElements, summaryHtml, extraProps)` | Registers a custom page definition in `app`. Must be called once at startup. |
2646
+ | `getCustomPageDefinition(store, siteId, pageUrl)` | Retrieves the custom page definition for a given siteId and pageUrl |
2647
+ | `resetCustomPages(configStore, store, siteId)` | Resets per-session data from the global definitions. |
2648
+ | `setCustomPageData(store, siteId, pageUrl, dataObject)` | Sets or replaces the data object used during submission. |
2649
+ | `setCustomPageSummaryElements(store, siteId, pageUrl, summaryElements)` | Sets what appears in the review page summary. |
2650
+ | `clearCustomPageErrors(store, siteId, pageUrl)` | Clears validation errors. |
2651
+ | `addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject)` | Adds a validation error. |
2652
+ | `setCustomPageEmail(store, siteId, pageUrl, arrayOfEmailObjects)` | Defines email sections for the confirmation email using `dsf-email-templates` components. |
2653
+ | `setCustomPageProperty(store, siteId, pageUrl, property, value, isDefinition = false)` | Sets a custom property on a given custom page definition or instance |
2654
+ | `getCustomPageProperty(store, siteId, pageUrl, property, isDefinition = false)` | Gets a custom property from a given custom page definition or instance. |
2655
+
2656
+ ---
2657
+ ##### Example
2658
+
2659
+ Below is a practical example of a custom **Declarations** page added to an existing service.
2660
+
2661
+ ```js
2662
+ import initializeGovCyExpressService from '@gov-cy/govcy-express-services';
2663
+ import {
2664
+ defineCustomPages, resetCustomPages,
2665
+ clearCustomPageErrors, addCustomPageError,
2666
+ setCustomPageData, setCustomPageSummaryElements,
2667
+ setCustomPageEmail, getCustomPageProperty, setCustomPageProperty
2668
+ } from '@gov-cy/govcy-express-services/customPages';
2669
+
2670
+ // Initialize the service
2671
+ const service = initializeGovCyExpressService({
2672
+ beforeMount({ siteRoute, app }) {
2673
+
2674
+ // ==========================================================
2675
+ // 1️⃣ DEFINE GLOBAL CUSTOM PAGE CONFIGS (once per app)
2676
+ // ==========================================================
2677
+ defineCustomPages(
2678
+ app, // <-- using app as store (global config)
2679
+ "cso", // siteId
2680
+ "/cso/custom", // pageUrl
2681
+ { en: "My custom section", el: "Προσαρμοσμένη ενότητα" }, // pageTitle
2682
+ "qualifications", // insertAfterPageUrl
2683
+ [ // errors
2684
+ {
2685
+ id: "custom-error",
2686
+ text: {
2687
+ en: "This is a custom error custom",
2688
+ el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2689
+ }
2690
+ }
2691
+ ],
2692
+ [ // summaryElements
2693
+ {
2694
+ key:
2695
+ {
2696
+ en: "Extra value",
2697
+ el: "Πρόσθετη τιμή"
2698
+ },
2699
+ value: []
2700
+ }
2701
+ ],
2702
+ false,
2703
+ { // other custom properties if needed like nextPage or initial data
2704
+ nextPage: "/cso/memberships", // custom property nextPage. Not needed by Express but useful for the custom logic
2705
+ data : // custom initial data. Useful when you need the data model to be standard or pre-populated
2706
+ {
2707
+ something: "",
2708
+ }
2709
+ }
2710
+ );
2711
+
2712
+ // ==========================================================
2713
+ // 2️⃣ MIDDLEWARE: SET UP SESSION DATA FROM GLOBAL DEFINITIONS
2714
+ // ==========================================================
2715
+ app.use((req, res, next) => {
2716
+ // Initialize session data for custom pages
2717
+ req.session.siteData ??= {};
2718
+ req.session.siteData["cso"] ??= {};
2719
+
2720
+ // Reset session copies if missing (first visit)
2721
+ if (!req.session.siteData["cso"].customPages) {
2722
+ resetCustomPages(app, req.session, "cso"); // 🔁 deep copy from app to session
2723
+ req.session.save((err) => {
2724
+ if (err) console.error("⚠️ Error initializing customPages:", err);
2725
+ });
2726
+ }
2727
+
2728
+ next();
2729
+ });
2730
+
2731
+ // ==========================================================
2732
+ // 3️⃣ CUSTOM ROUTES (still per-user)
2733
+ // ==========================================================
2734
+
2735
+ // GET `/cso/custom`
2736
+ siteRoute("cso", "get", "/cso/custom", (req, res) => {
2737
+
2738
+ // Render the custom page using the session data
2739
+ // It is important for POST to add the csrfToken and to pass on the route query parameter
2740
+ res.send(`<form action="/cso/custom${(req.query.route === "review")?"?route=review":""}" method="post">
2741
+ custom for ${req.params.siteId}
2742
+ <input type="hidden" name="_csrf" value="${req.csrfToken()}">
2743
+ <button type="submit">Submit custom page</button>
2744
+ </form>`);
2745
+
2746
+ });
2747
+
2748
+ // POST `/cso/custom`
2749
+ siteRoute("cso", "post", "/cso/custom", (req, res) => {
2750
+
2751
+ // Update custom page `data` dynamically`
2752
+ setCustomPageData(req.session, "cso", "/cso/custom", {
2753
+ something: "123",
2754
+ });
2755
+
2756
+ // Update `summary elements` dynamically (example)
2757
+ setCustomPageSummaryElements(req.session, "cso", "/cso/custom",
2758
+ [
2759
+ {
2760
+ key: {
2761
+ en: "Extra value",
2762
+ el: "Πρόσθετη τιμή"
2763
+ },
2764
+ value: [
2765
+ {
2766
+ element: "textElement",
2767
+ params: {
2768
+ text: {
2769
+ en: "123 Changed",
2770
+ el: "123 Αλλάχθηκε"
2771
+ },
2772
+ type: "span",
2773
+ showNewLine: true,
2774
+ }
2775
+ }
2776
+ ]
2777
+ }
2778
+ ]);
2779
+
2780
+ // Update the custom page `email`
2781
+ setCustomPageEmail(req.session, "cso", "/cso/custom", [
2782
+ {
2783
+ component: "bodyKeyValue",
2784
+ params: {
2785
+ type: "ul",
2786
+ items: [
2787
+ { key: "Extra value", value: "123" },
2788
+ { key: "Priority level", value: "High" },
2789
+ ],
2790
+ },
2791
+ }
2792
+ ]);
2793
+
2794
+ // clear any previous errors
2795
+ clearCustomPageErrors(req.session, "cso", "/cso/custom");
2796
+
2797
+ // Add a custom error
2798
+ // addCustomPageError(req.session, "cso", "/cso/custom", {
2799
+ // id: "custom-error",
2800
+ // text: {
2801
+ // en: "This is a custom error custom",
2802
+ // el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2803
+ // }
2804
+ // });
2805
+
2806
+ //if route is review, redirect to review page else go to nextPage property
2807
+ if (req.query.route === "review") {
2808
+ res.redirect("/cso/review");
2809
+ return;
2810
+ } else {
2811
+ //example of using custom property nextPage
2812
+ res.redirect(getCustomPageProperty(req.session, "cso", "/cso/custom", "nextPage", false));
2813
+ return;
2814
+ }
2815
+ });
2816
+
2817
+
2818
+ // ==========================================================
2819
+ },
2820
+ });
2821
+
2822
+
2823
+ // Start the server
2824
+ service.startServer();
2825
+
2826
+ ```
2827
+
2828
+
2606
2829
  ### 🛣️ Routes
2607
2830
  The project uses express.js to serve the following routes:
2608
2831
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.",
5
5
  "author": "DMRID - DSF Team",
6
6
  "license": "MIT",
@@ -8,7 +8,12 @@
8
8
  "main": "./src/index.mjs",
9
9
  "module": "./src/index.mjs",
10
10
  "exports": {
11
- "import": "./src/index.mjs"
11
+ ".": {
12
+ "import": "./src/index.mjs"
13
+ },
14
+ "./customPages": {
15
+ "import": "./src/utils/govcyCustomPages.mjs"
16
+ }
12
17
  },
13
18
  "files": [
14
19
  "src"
package/src/index.mjs CHANGED
@@ -35,7 +35,7 @@ import { logger } from "./utils/govcyLogger.mjs";
35
35
 
36
36
  import fs from 'fs';
37
37
 
38
- export default function initializeGovCyExpressService() {
38
+ export default function initializeGovCyExpressService(opts = {}) {
39
39
  const app = express();
40
40
 
41
41
  // Add this line before session middleware
@@ -152,6 +152,46 @@ export default function initializeGovCyExpressService() {
152
152
  govcyFileUpload
153
153
  );
154
154
 
155
+ // ==========================================================
156
+ // Custom pages
157
+ // ==========================================================
158
+ /**
159
+ * siteRoute helper:
160
+ * Registers a route NOT under /:siteId, but injects req.params.siteId manually.
161
+ */
162
+ const siteRoute = (siteId, method, path, ...handlers) => {
163
+ if (typeof app[method] !== "function") {
164
+ throw new Error(`Unsupported HTTP method: ${method}`);
165
+ }
166
+
167
+ // Middleware to manually inject the siteId param
168
+ const injectSiteId = (req, res, next) => {
169
+ req.params = req.params || {};
170
+ req.params.siteId = siteId;
171
+ next();
172
+ };
173
+
174
+ // Chain your standard middlewares AFTER injection
175
+ const wrappedHandlers = [
176
+ injectSiteId,
177
+ serviceConfigDataMiddleware,
178
+ requireAuth,
179
+ naturalPersonPolicy,
180
+ govcyServiceEligibilityHandler(),
181
+ ...handlers,
182
+ ];
183
+
184
+ // ✅ Register under a plain path (no /:siteId/)
185
+ // e.g. path = "/custom" → registers "/custom" only
186
+ app[method](path, ...wrappedHandlers);
187
+ };
188
+
189
+ // Allow custom routes
190
+ if (typeof opts.beforeMount === "function") {
191
+ opts.beforeMount({ siteRoute, app });
192
+ }
193
+ // ==========================================================
194
+
155
195
  // 🗃️ -- ROUTE: Handle POST requests for file uploads inside multipleThings (edit)
156
196
  app.post('/apis/:siteId/:pageUrl/multiple/edit/:index/upload',
157
197
  serviceConfigDataMiddleware,
@@ -299,13 +339,13 @@ export default function initializeGovCyExpressService() {
299
339
  );
300
340
 
301
341
  // ----- `updateMyDetails` handling
302
-
342
+
303
343
  // 🔀➡️ -- ROUTE coming from incoming update my details /:siteId/:pageUrl/update-my-details-response
304
- app.post('/:siteId/:pageUrl/update-my-details-response',
305
- serviceConfigDataMiddleware,
306
- requireAuth,
307
- naturalPersonPolicy,
308
- govcyServiceEligibilityHandler(true),
344
+ app.post('/:siteId/:pageUrl/update-my-details-response',
345
+ serviceConfigDataMiddleware,
346
+ requireAuth,
347
+ naturalPersonPolicy,
348
+ govcyServiceEligibilityHandler(true),
309
349
  govcyUpdateMyDetailsPostHandler());
310
350
  // ----- `updateMyDetails` handling
311
351
 
@@ -89,7 +89,10 @@ export function govcyReviewPageHandler() {
89
89
  if (fieldKey === "type") continue;
90
90
  // Push each field error to summary items
91
91
  summaryItems.push({
92
- link: govcyResources.constructPageUrl(siteId, fieldErr.pageUrl, "review"),
92
+ link: (err.type === "custom" ? // Custom pages
93
+ `/${fieldErr.pageUrl}?route=review` :
94
+ govcyResources.constructPageUrl(siteId, fieldErr.pageUrl, "review")
95
+ ),
93
96
  text: fieldErr.message
94
97
  });
95
98
  }
@@ -10,6 +10,7 @@ import { sendEmail } from "../utils/govcyNotification.mjs"
10
10
  import { evaluatePageConditions } from "../utils/govcyExpressions.mjs";
11
11
  import { createUmdManualPageTemplate } from "./govcyUpdateMyDetails.mjs"
12
12
  import { validateMultipleThings } from "../utils/govcyMultipleThingsValidation.mjs";
13
+ import { resetCustomPages } from "../utils/govcyCustomPages.mjs";
13
14
 
14
15
  /**
15
16
  * Middleware to handle review page form submission
@@ -25,12 +26,12 @@ export function govcyReviewPostHandler() {
25
26
  let validationErrors = {};
26
27
  // to be used for sending email
27
28
  let updateMyDetailsData = null;
28
-
29
+
29
30
  // Loop through all pages in the service
30
31
  for (const page of service.pages) {
31
32
  //get page url
32
33
  const pageUrl = page.pageData.url;
33
-
34
+
34
35
  // to be used for sending email
35
36
  // get updateMyDetails data if not found before
36
37
  if (!updateMyDetailsData) {
@@ -52,13 +53,13 @@ export function govcyReviewPostHandler() {
52
53
  if (page.updateMyDetails) {
53
54
  logger.debug("Validating UpdateMyDetails page during review POST", { siteId, pageUrl });
54
55
  // Build the manual UMD page template
55
- const umdTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
56
-
56
+ const umdTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
57
+
57
58
  // Extract the form element
58
- formElement = umdTemplate .sections
59
+ formElement = umdTemplate.sections
59
60
  .flatMap(section => section.elements)
60
61
  .find(el => el.element === "form");
61
-
62
+
62
63
  if (!formElement) {
63
64
  logger.error("🚨 UMD form element not found during review validation", { siteId, pageUrl });
64
65
  return handleMiddlewareError("🚨 UMD form element not found during review validation", 500, next);
@@ -107,6 +108,53 @@ export function govcyReviewPostHandler() {
107
108
  validationErrors = { ...validationErrors, ...errors };
108
109
  }
109
110
 
111
+ // ==========================================================
112
+ // Custom pages
113
+ // ==========================================================
114
+ // Handle custom summary validation blocks from session
115
+ const customPages = dataLayer.getSiteCustomPages(req.session, siteId);
116
+
117
+ // Convert existing validationErrors object to ordered array of [pageUrl, errorObj]
118
+ // so we can splice into it in the correct order.
119
+ let orderedErrors = Object.entries(validationErrors);
120
+
121
+ for (const [key, block] of Object.entries(customPages)) {
122
+ if (Array.isArray(block.errors) && block.errors.length > 0) {
123
+ const customErrObj = {};
124
+ for (const err of block.errors) {
125
+ customErrObj[err.id] = {
126
+ id: err.id,
127
+ message: err.text, // ⚙️ uses your structure
128
+ pageUrl: err.pageUrl || key
129
+ };
130
+ }
131
+
132
+ const newErrorEntry = [
133
+ key,
134
+ {
135
+ type: "custom", // mark it as custom for debug clarity
136
+ ...customErrObj
137
+ }
138
+ ];
139
+
140
+ // If block.insertAfter exists, insert after that page’s index
141
+ if (block.insertAfterPageUrl) {
142
+ const idx = orderedErrors.findIndex(([pageUrl]) => pageUrl === block.insertAfterPageUrl);
143
+ if (idx >= 0) {
144
+ orderedErrors.splice(idx + 1, 0, newErrorEntry); // insert after
145
+ continue; // move to next custom block
146
+ }
147
+ }
148
+
149
+ // Fallback: append to top if insertAfter not found or not defined
150
+ orderedErrors.unshift(newErrorEntry);
151
+ }
152
+ }
153
+
154
+ // Convert back into a normal object
155
+ validationErrors = Object.fromEntries(orderedErrors);
156
+ // ==========================================================
157
+
110
158
  // ❌ Return validation errors if any exist
111
159
  if (Object.keys(validationErrors).length > 0) {
112
160
  logger.debug("🚨 Validation errors:", validationErrors, req);
@@ -163,7 +211,7 @@ export function govcyReviewPostHandler() {
163
211
  // Add the reference number to the submission data
164
212
  submissionData.referenceNumber = referenceNo;
165
213
  logger.info("✅ Data submitted", siteId, referenceNo);
166
-
214
+
167
215
  // Get the user email address
168
216
  let emailAddress = "";
169
217
  // if Update my details not provided the use user email
@@ -174,25 +222,33 @@ export function govcyReviewPostHandler() {
174
222
  }
175
223
  // add contact email to submission data
176
224
  submissionData.contactEmailAddress = emailAddress;
177
-
225
+
178
226
  // handle data layer submission
179
227
  dataLayer.storeSiteSubmissionData(
180
228
  req.session,
181
229
  siteId,
182
230
  submissionData);
183
-
231
+
184
232
  //-- Send email to user
185
233
  // Generate the email body
186
234
  let emailBody = generateSubmitEmail(service, submissionData.printFriendlyData, referenceNo, req);
187
-
235
+
236
+ // ==========================================================
237
+ // Custom pages
238
+ // =========================================================
239
+ // 🆕 Reset per-session custom pages from the global app definition
240
+ const app = req.app; // ✅ Express automatically provides this
241
+ resetCustomPages(app, req.session, siteId);
242
+ // ==========================================================
243
+
188
244
  // Send the email
189
245
  sendEmail(
190
- service.site.title[service.site.lang],
191
- emailBody,
192
- [emailAddress],
246
+ service.site.title[service.site.lang],
247
+ emailBody,
248
+ [emailAddress],
193
249
  "eMail").catch(err => {
194
250
  logger.error("Email sending failed (async):", err);
195
- });
251
+ });
196
252
  // --- End of email sending
197
253
 
198
254
  logger.debug("🔄 Redirecting to success page:", req);
@@ -0,0 +1,260 @@
1
+ import * as govcyResources from "../resources/govcyResources.mjs";
2
+
3
+ /**
4
+ * Defines custom pages for a given siteId and pageUrl.
5
+ * This function initializes the custom pages in the store and sets up the necessary data structure.
6
+ * @param {object} store The session store (usually req.app)
7
+ * @param {string} siteId The site identifier (e.g., `"cso"`)
8
+ * @param {string} pageUrl The URL of the custom page (e.g., `"/cso/custom"`)
9
+ * @param {string} pageTitle The title of the custom page (e.g., `{ en: "My custom section", el: "Προσαρμοσμένη ενότητα" }`)
10
+ * @param {string} insertAfterPageUrl The page URL to insert the custom page after (e.g., `"qualifications"`)
11
+ * @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,
19
+ pageUrl,
20
+ pageTitle,
21
+ insertAfterPageUrl,
22
+ errors,
23
+ summaryElements,
24
+ summaryHtml = false,
25
+ extraProps = {}
26
+ ) {
27
+ // Initialize custom pages in session if not already set
28
+ store.siteData ??= {};
29
+ store.siteData[siteId] ??= {};
30
+ store.siteData[siteId].customPagesDefinition ??= {}; // Initialize custom pages definition
31
+ store.siteData[siteId].customPagesDefinition[pageUrl] ??= {}; // Initialize specific page definition
32
+
33
+ // ✅ Normalize error objects: add pageUrl if missing
34
+ let normalizedErrors = [];
35
+ if (Array.isArray(errors)) {
36
+ normalizedErrors = errors.map(err => ({
37
+ ...err,
38
+ // pageUrl replace first `/` with empty string to ensure it is relative
39
+ pageUrl: err.pageUrl || pageUrl.replace(/^\//, "") // ← ensure pageUrl is set
40
+ }));
41
+ }
42
+
43
+ // Initialize the custom summary actions
44
+ let summaryActions = [];
45
+
46
+ // 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
+ };
56
+
57
+ // Construct default summaryActions
58
+ if (pageTitle && pageUrl) {
59
+ definition.summaryActions = [
60
+ {
61
+ text: govcyResources.staticResources.text.change,
62
+ classes: "govcy-d-print-none",
63
+ href: `${pageUrl}?route=review`,
64
+ visuallyHiddenText: pageTitle
65
+ }
66
+ ];
67
+ }
68
+
69
+ // Save definition
70
+ store.siteData[siteId].customPagesDefinition[pageUrl] = definition;
71
+
72
+ }
73
+
74
+ /**
75
+ * Retrieves the custom page definition for a given siteId and pageUrl.
76
+ *
77
+ * @param {object} store The custom pages configuration store. Should be req.app
78
+ * @param {string} siteId The site id
79
+ * @param {string} pageUrl The URL of the custom page
80
+ * @returns {object} The custom page definition or an empty object if not found.
81
+ */
82
+ export function getCustomPageDefinition(store, siteId, pageUrl) {
83
+ return store.siteData?.[siteId]?.customPagesDefinition?.[pageUrl] || {};
84
+ }
85
+
86
+ /**
87
+ * Resets the custom pages for a given siteId.
88
+ * This will deep copy the customPagesDefinition to customPages.
89
+ * This is useful for initializing or resetting the custom pages.
90
+ *
91
+ * @param {object} configStore The custom pages configuration store. Should be req.app
92
+ * @param {object} store The data layer store. Should be the req.session
93
+ * @param {string} siteId The site id
94
+ */
95
+ export function resetCustomPages(configStore, store, siteId) {
96
+ if (!configStore?.siteData?.[siteId]?.customPagesDefinition) return;
97
+ // Reset custom pages for the given siteId based on the customPagesDefinition
98
+ store.siteData[siteId].customPages = JSON.parse(JSON.stringify(configStore.siteData[siteId].customPagesDefinition))
99
+ }
100
+
101
+ // ==========================================================
102
+ // 🧰 Custom Page Error Helpers
103
+ // ==========================================================
104
+
105
+ /**
106
+ * Sets a custom property on a given custom page definition or instance.
107
+ *
108
+ * @param {object} store - The store containing siteData.
109
+ * @param {string} siteId - The site identifier.
110
+ * @param {string} pageUrl - The custom page URL.
111
+ * @param {string} property - The property name to set.
112
+ * @param {*} value - The value to assign.
113
+ * @param {boolean} [isDefinition=false] - Whether to modify the global definition instead of session data.
114
+ */
115
+ export function setCustomPageProperty(store, siteId, pageUrl, property, value, isDefinition = false) {
116
+ const container = isDefinition
117
+ ? store.siteData?.[siteId]?.customPagesDefinition
118
+ : store.siteData?.[siteId]?.customPages;
119
+
120
+ if (!container || !container[pageUrl]) {
121
+ console.warn(`⚠️ setCustomPageProperty: page '${pageUrl}' not found for site '${siteId}'`);
122
+ return;
123
+ }
124
+
125
+ container[pageUrl][property] = value;
126
+ }
127
+
128
+ /**
129
+ * Gets a custom property from a given custom page definition or instance.
130
+ *
131
+ * @param {object} store - The store containing siteData.
132
+ * @param {string} siteId - The site identifier.
133
+ * @param {string} pageUrl - The custom page URL.
134
+ * @param {string} property - The property name to get.
135
+ * @param {boolean} [isDefinition=false] - Whether to read from the global definition instead of session data.
136
+ * @returns {*} The value of the property, or undefined if not found.
137
+ */
138
+ export function getCustomPageProperty(store, siteId, pageUrl, property, isDefinition = false) {
139
+ const container = isDefinition
140
+ ? store.siteData?.[siteId]?.customPagesDefinition
141
+ : store.siteData?.[siteId]?.customPages;
142
+
143
+ return container?.[pageUrl]?.[property];
144
+ }
145
+
146
+
147
+ /**
148
+ * Sets the data object for a given custom page.
149
+ * Overwrites the existing data (does not merge).
150
+ *
151
+ * @param {Object} store - The store containing siteData.
152
+ * @param {string} siteId - The site identifier.
153
+ * @param {string} pageUrl - The URL of the custom page.
154
+ * @param {Object} data - The data to store.
155
+ */
156
+ export function setCustomPageData(store, siteId, pageUrl, data) {
157
+
158
+ const target = store.siteData?.[siteId]?.customPages?.[pageUrl];
159
+ if (!target) {
160
+ console.warn(`⚠️ setCustomPageData: page '${pageUrl}' not found for site '${siteId}'`);
161
+ return;
162
+ }
163
+
164
+ target.data = data; // overwrite existing data
165
+ }
166
+
167
+ /**
168
+ * Sets the email array with dsf-email-templates objects for a given custom page.
169
+ * Overwrites the existing data (does not merge).
170
+ *
171
+ * @param {Object} store - The store containing siteData.
172
+ * @param {string} siteId - The site identifier.
173
+ * @param {string} pageUrl - The URL of the custom page.
174
+ * @param {Array<Object>} arrayOfEmailObjects - Array of dsf-email-templates objects.
175
+ */
176
+ export function setCustomPageEmail(store, siteId, pageUrl, arrayOfEmailObjects) {
177
+
178
+ const target = store.siteData?.[siteId]?.customPages?.[pageUrl];
179
+ if (!target) {
180
+ console.warn(`⚠️ setCustomPageData: page '${pageUrl}' not found for site '${siteId}'`);
181
+ return;
182
+ }
183
+
184
+ if (!Array.isArray(arrayOfEmailObjects)) {
185
+ console.warn(`⚠️ setCustomPageEmail: expected array for custom page '${normalizedPageUrl}', got`, typeof arrayOfEmailObjects);
186
+ return;
187
+ }
188
+
189
+ target.email = arrayOfEmailObjects; // overwrite existing data
190
+ }
191
+
192
+ /**
193
+ * Sets the summaryElements array for a given custom page.
194
+ * Replaces the existing summaryElements array.
195
+ *
196
+ * @param {Object} store - The store containing siteData.
197
+ * @param {string} siteId - The site identifier.
198
+ * @param {string} pageUrl - The URL of the custom page.
199
+ * @param {Array} summaryElements - Array of summary element definitions.
200
+ */
201
+ export function setCustomPageSummaryElements(store, siteId, pageUrl, summaryElements) {
202
+
203
+ const target = store.siteData?.[siteId]?.customPages?.[pageUrl];
204
+ if (!target) {
205
+ console.warn(`⚠️ setCustomPageSummaryElements: page '${pageUrl}' not found for site '${siteId}'`);
206
+ return;
207
+ }
208
+
209
+ target.summaryElements = Array.isArray(summaryElements) ? summaryElements : [];
210
+ }
211
+
212
+ /**
213
+ * Clears all errors for a given custom page.
214
+ * Works on either customPages or customPagesDefinition.
215
+ *
216
+ * @param {Object} store - The store containing siteData.
217
+ * @param {string} siteId - The site identifier.
218
+ * @param {string} pageUrl - The URL of the custom page.
219
+ */
220
+ export function clearCustomPageErrors(store, siteId, pageUrl) {
221
+
222
+ const data = store.siteData?.[siteId]?.customPages?.[pageUrl];
223
+
224
+ if (data) data.errors = [];
225
+ }
226
+
227
+ /**
228
+ * Adds an error to a given custom page (definition or data).
229
+ * Auto-fills pageUrl and ensures array exists.
230
+ *
231
+ * @param {Object} store - The store containing siteData.
232
+ * @param {string} siteId - The site identifier.
233
+ * @param {string} pageUrl - The URL of the custom page.
234
+ * @param {string} errorId - Unique identifier for the error.
235
+ * @param {Object} errorTextObject - Multilingual Object containing localized error texts.
236
+ */
237
+ export function addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject) {
238
+ const normalizedPageUrl = pageUrl.replace(/^\/+/, "");
239
+
240
+ const target =
241
+ store.siteData?.[siteId]?.customPages?.[pageUrl]
242
+
243
+ if (!target) {
244
+ console.warn(`⚠️ addCustomPageError: page '${pageUrl}' not found for site '${siteId}'`);
245
+ return;
246
+ }
247
+
248
+ target.errors ??= [];
249
+
250
+ // ✅ Prevent duplicate errors by ID
251
+ if (target.errors.some(err => err.id === errorId)) return;
252
+
253
+ // Normalize and push error
254
+ target.errors.push({
255
+ id: errorId,
256
+ text: errorTextObject,
257
+ pageUrl: normalizedPageUrl,
258
+ });
259
+
260
+ }
@@ -378,6 +378,23 @@ export function getSiteLoadData(store, siteId) {
378
378
  return null;
379
379
  }
380
380
 
381
+ /**
382
+ * Get the site's custom pages from the store for custom pages
383
+ *
384
+ * @param {object} store The session store
385
+ * @param {string} siteId |The site id
386
+ * @returns The site custom pages or null if none exist.
387
+ */
388
+ export function getSiteCustomPages(store, siteId) {
389
+ const customPages = store?.siteData?.[siteId]?.customPages || {};
390
+
391
+ if (customPages ) {
392
+ return customPages ;
393
+ }
394
+
395
+ return null;
396
+ }
397
+
381
398
  /**
382
399
  * Get the site's reference number from load data from the store
383
400
  *
@@ -50,13 +50,13 @@ export function prepareSubmissionData(req, siteId, service) {
50
50
  if (page.updateMyDetails) {
51
51
  logger.debug("Preparing submission data for UpdateMyDetails page", { siteId, pageUrl });
52
52
  // Build the manual UMD page template
53
- const umdTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
54
-
53
+ const umdTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
54
+
55
55
  // Extract the form element
56
- formElement = umdTemplate .sections
56
+ formElement = umdTemplate.sections
57
57
  .flatMap(section => section.elements)
58
58
  .find(el => el.element === "form");
59
-
59
+
60
60
  if (!formElement) {
61
61
  logger.error("🚨 UMD form element not found during prepareSubmissionData", { siteId, pageUrl });
62
62
  return handleMiddlewareError("🚨 UMD form element not found during prepareSubmissionData", 500, next);
@@ -190,11 +190,51 @@ export function prepareSubmissionData(req, siteId, service) {
190
190
 
191
191
  // Get the renderer data from the session store
192
192
  const reviewSummaryList = generateReviewSummary(printFriendlyData, req, siteId, false);
193
+
194
+ // ==========================================================
195
+ // Custom pages
196
+ // ==========================================================
197
+ const customPages = dataLayer.getSiteCustomPages(req.session, siteId) || {};
198
+
199
+ // Convert submissionData keys into ordered array for splicing
200
+ const orderedEntries = Object.entries(submissionData);
201
+
202
+ // Loop through each custom page
203
+ for (const [customKey, customPage] of Object.entries(customPages)) {
204
+ const pageData = customPage?.data || "";
205
+ const insertAfter = customPage?.insertAfterPageUrl; // normalize
206
+
207
+ // Build a new entry for this custom page
208
+ const customEntry = [customKey, pageData];
209
+
210
+ if (insertAfter) {
211
+ // Find the index of the page to insert after
212
+ const idx = orderedEntries.findIndex(([pageUrl]) => pageUrl === insertAfter);
213
+ if (idx >= 0) {
214
+ // Insert right after the matched page
215
+ orderedEntries.splice(idx + 1, 0, customEntry);
216
+ continue;
217
+ }
218
+ }
219
+
220
+ // Fallback: append to the top
221
+ orderedEntries.unshift(customEntry);
222
+ }
223
+
224
+ // Convert back to an object in the new order
225
+ const orderedSubmissionData = Object.fromEntries(orderedEntries);
226
+
227
+ logger.info("Submission Data with custom pages merged");
228
+
229
+ // ==========================================================
230
+ // END custom page merge
231
+ // ==========================================================
232
+
193
233
  // Prepare the submission data object
194
234
  return {
195
235
  submissionUsername: dataLayer.getUser(req.session).name,
196
236
  submissionEmail: dataLayer.getUser(req.session).email,
197
- submissionData: submissionData, // Raw data as submitted by the user in each page
237
+ submissionData: orderedSubmissionData, // Raw data as submitted by the user in each page
198
238
  submissionDataVersion: service.site?.submissionDataVersion || service.site?.submission_data_version || "", // The submission data version
199
239
  printFriendlyData: printFriendlyData, // Print-friendly data
200
240
  rendererData: reviewSummaryList, // Renderer data of the summary list
@@ -259,12 +299,12 @@ export function preparePrintFriendlyData(req, siteId, service) {
259
299
 
260
300
  let pageTemplate = page.pageTemplate;
261
301
  let pageTitle = page.pageData.title || {};
262
-
302
+
263
303
 
264
304
  // ----- MultipleThings hub handling
265
305
  if (page.updateMyDetails) {
266
306
  // create the page template
267
- pageTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req );
307
+ pageTemplate = createUmdManualPageTemplate(siteId, service.site.lang, page, req);
268
308
  // set the page title
269
309
  pageTitle = govcyResources.staticResources.text.updateMyDetailsTitle;
270
310
  }
@@ -642,6 +682,7 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
642
682
 
643
683
  // Add inner summary list to the main summary list
644
684
  let outerSummaryList = {
685
+ "pageUrl": pageUrl, // add pageUrl for change link
645
686
  "key": pageTitle,
646
687
  "value": [summaryListInner],
647
688
  "actions": [ //add change link
@@ -664,6 +705,76 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
664
705
 
665
706
  }
666
707
 
708
+ // ==========================================================
709
+ // CustomPages
710
+ // ==========================================================
711
+ // Custom summaries are stored under:
712
+ // session.siteData[siteId].customPages
713
+ // Each entry may include:
714
+ // insertAfter: "pageUrl"
715
+ // summary: array of summaryList items (same shape as generateReviewSummary output)
716
+ // OR
717
+ // html: pre-rendered multilingual HTML block
718
+ // ==========================================================
719
+
720
+ const customPages = dataLayer.getSiteCustomPages(req.session, siteId);
721
+
722
+ for (const [key, block] of Object.entries(customPages)) { //
723
+ // Build the structure the same way as a normal section
724
+ let customEntry;
725
+
726
+ let customValue = [];
727
+
728
+ if (block.summaryHtml) {
729
+ // Direct HTML block
730
+ customValue = [
731
+ {
732
+ element: "htmlElement",
733
+ params: { text: block.summaryHtml }
734
+ }
735
+ ];
736
+ } else if (block.summaryElements) {
737
+ // Already a ready-made summaryList object
738
+ customValue = [
739
+ {
740
+ element: "summaryList",
741
+ params: { items: block.summaryElements }
742
+ }
743
+ ];
744
+ } else {
745
+ // Fallback empty block
746
+ continue; // skip this block if no valid content
747
+ }
748
+
749
+ customEntry = {
750
+ key: block.pageTitle || { en: key, el: key },
751
+ value: customValue,
752
+ actions: block.summaryActions || [] // Optional actions, e.g. change links
753
+ };
754
+
755
+ // If showChangeLinks, remove the actions key
756
+ if (!showChangeLinks) {
757
+ delete customEntry.actions;
758
+ }
759
+
760
+ // Insert custom summary section after given page
761
+ if (block.insertAfterPageUrl) {
762
+ const idx = summaryList.params.items.findIndex(
763
+ (p) => p.pageUrl === block.insertAfterPageUrl);
764
+ if (idx >= 0) {
765
+ summaryList.params.items.splice(idx + 1, 0, customEntry);
766
+ } else {
767
+ summaryList.params.items.push(customEntry); // fallback append
768
+ }
769
+ } else {
770
+ summaryList.params.items.unshift(customEntry); // fallback append
771
+ }
772
+ }
773
+
774
+ // ==========================================================
775
+ // END customPages merge
776
+ // ==========================================================
777
+
667
778
  return summaryList;
668
779
  }
669
780
 
@@ -680,6 +791,14 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
680
791
  */
681
792
  export function generateSubmitEmail(service, submissionData, submissionId, req) {
682
793
  let body = [];
794
+ // ==========================================================
795
+ // Custom page
796
+ // ==========================================================
797
+ const customPages = dataLayer.getSiteCustomPages(req.session, service.site.id) || {};
798
+ // Build a lookup for custom pages by insertAfterPageUrl
799
+ const customPageEntries = Object.entries(customPages);
800
+ let pagesInBody = [];
801
+ // ===========================================================
683
802
 
684
803
  //check if there is submission Id
685
804
  if (submissionId) {
@@ -772,8 +891,57 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
772
891
  });
773
892
  }
774
893
 
894
+ // ==========================================================
895
+ // Custom page
896
+ // ==========================================================
897
+ // 🆕 Check for custom pages that should appear after this one
898
+ for (const [customKey, customPage] of customPageEntries) {
899
+ const insertAfter = customPage.insertAfterPageUrl;
900
+ if (insertAfter && insertAfter === pageUrl && Array.isArray(customPage.email)) {
901
+ pagesInBody.push(customPage.pageUrl); // Track custom page key for later
902
+ // Add data title to the body
903
+ body.push(
904
+ {
905
+ component: "bodyHeading",
906
+ params: { "headingLevel": 2 },
907
+ body: govcyResources.getLocalizeContent(customPage.pageTitle, req.globalLang)
908
+ }
909
+ )
910
+ logger.debug(`📧 Inserting custom page email '${customKey}' after '${pageUrl}'`);
911
+ body.push(...customPage.email);
912
+ }
913
+ }
914
+
915
+ // ==========================================================
916
+ }
917
+
918
+ // ==========================================================
919
+ // Custom pages - Handle leftover custom pages not yet inserted
920
+ // ==========================================================
921
+ for (const [customKey, customPage] of customPageEntries) {
922
+ // check if this custom page was already inserted
923
+ const wasInserted = pagesInBody.includes(customKey);
924
+ // check if it has email content
925
+ const hasEmail = Array.isArray(customPage.email);
926
+ // get its title
927
+ const pageTitle = customPage.pageTitle;
928
+
929
+ // If not inserted and has email content, prepend it to the body
930
+ if (!wasInserted && hasEmail) {
931
+ // Prepend its email content
932
+ body.unshift(...customPage.email);
933
+ // Add a heading for visibility
934
+ body.unshift({
935
+ component: "bodyHeading",
936
+ params: { headingLevel: 2 },
937
+ body: govcyResources.getLocalizeContent(pageTitle, req.globalLang)
938
+ });
775
939
 
940
+ logger.debug(`📧 Adding leftover custom page '${customKey}' (no insertAfter match)`);
941
+ }
776
942
  }
943
+ // ==========================================================
944
+
777
945
 
778
946
  let emailObject = govcyResources.getEmailObject(
779
947
  service.site.title,
@@ -790,86 +958,3 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
790
958
  return emailRenderer.renderFromJson(emailObject);
791
959
  }
792
960
 
793
-
794
-
795
- /*
796
- {
797
- "bank-details": {
798
- "formData": {
799
- "AccountName": "asd",
800
- "Iban": "CY12 0020 0123 0000 0001 2345 6789",
801
- "Swift": "BANKCY2NXXX",
802
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
803
- }
804
- },
805
- "answer-bank-boc": {
806
- "formData": {
807
- "Objection": "Object",
808
- "country": "Azerbaijan",
809
- "ObjectionReason": "ObjectionReasonCode1",
810
- "ObjectionExplanation": "asdsa",
811
- "DepositsBOCAttachment": "",
812
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
813
- }
814
- },
815
- "bank-settlement": {
816
- "formData": {
817
- "ReceiveSettlementExplanation": "",
818
- "ReceiveSettlementDate_day": "",
819
- "ReceiveSettlementDate_month": "",
820
- "ReceiveSettlementDate_year": "",
821
- "ReceiveSettlement": "no",
822
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
823
- }
824
- }
825
- }
826
-
827
-
828
-
829
- [
830
- {
831
- pageUrl: "personal-details",
832
- pageTitle: { en: "Personal data", el: "Προσωπικά στοιχεία" }, // from pageData.title in correct language
833
- fields: [
834
- [
835
- {
836
- id: "firstName",
837
- label: { en: "First Name", el: "Όνομα" },
838
- value: "John", // The actual user input value
839
- valueLabel: { en: "John", el: "John" } // Same label as the value for text inputs
840
- },
841
- {
842
- id: "lastName",
843
- label: { en: "Last Name", el: "Επίθετο" },
844
- value: "Doe", // The actual user input value
845
- valueLabel: { en: "Doe", el: "Doe" } // Same label as the value for text inputs
846
- },
847
- {
848
- id: "gender",
849
- label: { en: "Gender", el: "Φύλο" },
850
- value: "m", // The actual value ("male")
851
- valueLabel: { en: "Male", el: "Άντρας" } // The corresponding label for "male"
852
- },
853
- {
854
- id: "languages",
855
- label: { en: "Languages", el: "Γλώσσες" },
856
- value: ["en", "el"], // The selected values ["en", "el"]
857
- valueLabel: [
858
- { en: "English", el: "Αγγλικά" }, // Labels corresponding to "en" and "el"
859
- { en: "Greek", el: "Ελληνικά" }
860
- ]
861
- },
862
- {
863
- id: "birthDate",
864
- label: { en: "Birth Date", el: "Ημερομηνία Γέννησης" },
865
- value: "1990-01-13", // The actual value based on user input
866
- valueLabel: "13/1/1990" // Date inputs label will be conveted to D/M/YYYY
867
- }
868
- ]
869
- },
870
- ...
871
- ]
872
-
873
-
874
-
875
- */