@gov-cy/govcy-express-services 1.5.0 → 1.6.1

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
@@ -31,7 +31,7 @@ The APIs used for submission, temporary save and file uploads are not part of th
31
31
  - [📦 Full installation guide](#-full-installation-guide)
32
32
  - [🛠️ Usage](#%EF%B8%8F-usage)
33
33
  - [🔑 Authentication Middleware](#-authentication-middleware)
34
- -[CY Login Access Policies](#cy-login-access-policies)
34
+ - [cyLogin Access Policies](#cylogin-access-policies)
35
35
  - [🧩 Dynamic services](#-dynamic-services)
36
36
  - [Pages](#pages)
37
37
  - [Form vs static pages](#form-vs-static-pages)
@@ -214,6 +214,7 @@ Here is an example JSON config:
214
214
  {
215
215
  "site": {
216
216
  "id": "test",
217
+ "usesDSFSubmissionPlatform": true, //<-- Indicates whether the service uses the DSF submission platform (transforms submission data as needed)
217
218
  "cyLoginPolicies": ["naturalPerson"], //<-- Allowed CY Login policies
218
219
  "lang": "el", //<-- Default language
219
220
  "languages": [ //<-- Supported languages
@@ -773,6 +774,8 @@ Here is an example JSON config:
773
774
  Here are some details explaining the JSON structure:
774
775
 
775
776
  - `site` object: Contains information about the site, including the site ID, language, and footer links. See [govcy-frontend-renderer](https://github.com/gov-cy/govcy-frontend-renderer/tree/main#site-and-page-meta-data-explained) for more details. Some fields that are only specific to the govcy-express-forms project are the following:
777
+ - `usesDSFSubmissionPlatform`: A boolean that indicates whether the service uses the DSF submission platform (transforms submission data as needed)
778
+ - `cyLoginPolicies`: which types of authenticated CY Login profiles are allowed to access the service
776
779
  - `submissionDataVersion` : The submission data version,
777
780
  - `rendererVersion` : The govcy-frontend-renderer version,
778
781
  - `designSystemsVersion` : The govcy-design-system version,
@@ -2661,227 +2664,8 @@ To help back-end systems recognize the field as a file, the field's element name
2661
2664
  If these endpoints are not defined in the service JSON, the file upload logic is skipped entirely.
2662
2665
  Existing services will continue to work without modification.
2663
2666
 
2664
- ### ✨ Custom pages feature
2665
- The **Custom pages** feature allows developers to add service-specific custom pages that exist outside the standard Express Services JSON configuration.
2666
-
2667
- These pages can collect data, display conditional content, and inject custom sections into the **review** and **email** stages of the service flow.
2668
-
2669
- #### What do custom pages get out of the framework
2670
- 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:
2671
- - Login requirement
2672
- - User policy requirement
2673
- - Any service eligibility checks
2674
- - Csrf protection on POST
2675
- - Generic error page on errors
2676
-
2677
- The developers can also choose to:
2678
- - store values in the session to be submitted via the submission API
2679
- - define where and what the users sees in the `review` page
2680
- - define errors regarding the custom page in the `review` page
2681
- - define what the user receives the `email`
2682
-
2683
- #### What the framework expects from custom pages
2684
-
2685
- - Every custom page **must be defined** at startup using `defineCustomPages(app, siteId, pageUrl, ...)`.
2686
- - Custom pages are **not** automatically discovered, they must be registered explicitly.
2687
- - Each page must specify on **start up**:
2688
- - `pageTitle`: multilingual object for display in review and email
2689
- - `insertAfterPageUrl`: the page **after which** this custom page will appear
2690
- - `summaryElements`, `errors`, and `email` are managed dynamically in session
2691
- - **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.
2692
- - `extraProps` extra property stored in the session to be used by the developers as they wish
2693
- - During **runtime**, developers are responsible for setting:
2694
- - `data`: data to include in submission, using the `setCustomPageData` function
2695
- - `summaryElements`: what appears in the “Check your answers” review page, using the `setCustomPageSummaryElements` function
2696
- - `email`: array of [`dsf-email-templates`](https://github.com/gov-cy/dsf-email-templates) components for the submission confirmation email, using the `setCustomPageEmail`
2697
- - `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
2698
- #### Available methods
2699
-
2700
- | Method | Purpose |
2701
- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
2702
- | `defineCustomPages(store, siteId, pageUrl, pageTitle, insertAfterSummary, insertAfterPageUrl, errors, summaryElements, summaryHtml, extraProps)` | Registers a custom page definition in `app`. Must be called once at startup. |
2703
- | `getCustomPageDefinition(store, siteId, pageUrl)` | Retrieves the custom page definition for a given siteId and pageUrl |
2704
- | `resetCustomPages(configStore, store, siteId)` | Resets per-session data from the global definitions. |
2705
- | `setCustomPageData(store, siteId, pageUrl, dataObject)` | Sets or replaces the data object used during submission. |
2706
- | `setCustomPageSummaryElements(store, siteId, pageUrl, summaryElements)` | Sets what appears in the review page summary. |
2707
- | `clearCustomPageErrors(store, siteId, pageUrl)` | Clears validation errors. |
2708
- | `addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject)` | Adds a validation error. |
2709
- | `setCustomPageEmail(store, siteId, pageUrl, arrayOfEmailObjects)` | Defines email sections for the confirmation email using `dsf-email-templates` components. |
2710
- | `setCustomPageProperty(store, siteId, pageUrl, property, value, isDefinition = false)` | Sets a custom property on a given custom page definition or instance |
2711
- | `getCustomPageProperty(store, siteId, pageUrl, property, isDefinition = false)` | Gets a custom property from a given custom page definition or instance. |
2712
-
2713
- ---
2714
- ##### Example
2715
-
2716
- Below is a practical example of a custom **Declarations** page added to an existing service.
2717
-
2718
- ```js
2719
- import initializeGovCyExpressService from '@gov-cy/govcy-express-services';
2720
- import {
2721
- defineCustomPages, resetCustomPages,
2722
- clearCustomPageErrors, addCustomPageError,
2723
- setCustomPageData, setCustomPageSummaryElements,
2724
- setCustomPageEmail, getCustomPageProperty, setCustomPageProperty
2725
- } from '@gov-cy/govcy-express-services/customPages';
2726
-
2727
- // Initialize the service
2728
- const service = initializeGovCyExpressService({
2729
- beforeMount({ siteRoute, app }) {
2730
-
2731
- // ==========================================================
2732
- // 1️⃣ DEFINE GLOBAL CUSTOM PAGE CONFIGS (once per app)
2733
- // ==========================================================
2734
- defineCustomPages(
2735
- app, // <-- using app as store (global config)
2736
- "cso", // siteId
2737
- "/cso/custom", // pageUrl
2738
- { en: "My custom section", el: "Προσαρμοσμένη ενότητα" }, // pageTitle
2739
- "qualifications", // insertAfterPageUrl
2740
- [ // errors
2741
- {
2742
- id: "custom-error",
2743
- text: {
2744
- en: "This is a custom error custom",
2745
- el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2746
- }
2747
- }
2748
- ],
2749
- [ // summaryElements
2750
- {
2751
- key:
2752
- {
2753
- en: "Extra value",
2754
- el: "Πρόσθετη τιμή"
2755
- },
2756
- value: []
2757
- }
2758
- ],
2759
- false,
2760
- { // other custom properties if needed like nextPage or initial data
2761
- nextPage: "/cso/memberships", // custom property nextPage. Not needed by Express but useful for the custom logic
2762
- data : // custom initial data. Useful when you need the data model to be standard or pre-populated
2763
- {
2764
- something: "",
2765
- }
2766
- }
2767
- );
2768
-
2769
- // ==========================================================
2770
- // 2️⃣ MIDDLEWARE: SET UP SESSION DATA FROM GLOBAL DEFINITIONS
2771
- // ==========================================================
2772
- app.use((req, res, next) => {
2773
- // Initialize session data for custom pages
2774
- req.session.siteData ??= {};
2775
- req.session.siteData["cso"] ??= {};
2776
-
2777
- // Reset session copies if missing (first visit)
2778
- if (!req.session.siteData["cso"].customPages) {
2779
- resetCustomPages(app, req.session, "cso"); // 🔁 deep copy from app to session
2780
- req.session.save((err) => {
2781
- if (err) console.error("⚠️ Error initializing customPages:", err);
2782
- });
2783
- }
2784
-
2785
- next();
2786
- });
2787
-
2788
- // ==========================================================
2789
- // 3️⃣ CUSTOM ROUTES (still per-user)
2790
- // ==========================================================
2791
-
2792
- // GET `/cso/custom`
2793
- siteRoute("cso", "get", "/cso/custom", (req, res) => {
2794
-
2795
- // Render the custom page using the session data
2796
- // It is important for POST to add the csrfToken and to pass on the route query parameter
2797
- res.send(`<form action="/cso/custom${(req.query.route === "review")?"?route=review":""}" method="post">
2798
- custom for ${req.params.siteId}
2799
- <input type="hidden" name="_csrf" value="${req.csrfToken()}">
2800
- <button type="submit">Submit custom page</button>
2801
- </form>`);
2802
-
2803
- });
2804
-
2805
- // POST `/cso/custom`
2806
- siteRoute("cso", "post", "/cso/custom", (req, res) => {
2807
-
2808
- // Update custom page `data` dynamically`
2809
- setCustomPageData(req.session, "cso", "/cso/custom", {
2810
- something: "123",
2811
- });
2812
-
2813
- // Update `summary elements` dynamically (example)
2814
- setCustomPageSummaryElements(req.session, "cso", "/cso/custom",
2815
- [
2816
- {
2817
- key: {
2818
- en: "Extra value",
2819
- el: "Πρόσθετη τιμή"
2820
- },
2821
- value: [
2822
- {
2823
- element: "textElement",
2824
- params: {
2825
- text: {
2826
- en: "123 Changed",
2827
- el: "123 Αλλάχθηκε"
2828
- },
2829
- type: "span",
2830
- showNewLine: true,
2831
- }
2832
- }
2833
- ]
2834
- }
2835
- ]);
2836
-
2837
- // Update the custom page `email`
2838
- setCustomPageEmail(req.session, "cso", "/cso/custom", [
2839
- {
2840
- component: "bodyKeyValue",
2841
- params: {
2842
- type: "ul",
2843
- items: [
2844
- { key: "Extra value", value: "123" },
2845
- { key: "Priority level", value: "High" },
2846
- ],
2847
- },
2848
- }
2849
- ]);
2850
-
2851
- // clear any previous errors
2852
- clearCustomPageErrors(req.session, "cso", "/cso/custom");
2853
-
2854
- // Add a custom error
2855
- // addCustomPageError(req.session, "cso", "/cso/custom", {
2856
- // id: "custom-error",
2857
- // text: {
2858
- // en: "This is a custom error custom",
2859
- // el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2860
- // }
2861
- // });
2862
-
2863
- //if route is review, redirect to review page else go to nextPage property
2864
- if (req.query.route === "review") {
2865
- res.redirect("/cso/review");
2866
- return;
2867
- } else {
2868
- //example of using custom property nextPage
2869
- res.redirect(getCustomPageProperty(req.session, "cso", "/cso/custom", "nextPage", false));
2870
- return;
2871
- }
2872
- });
2873
-
2874
-
2875
- // ==========================================================
2876
- },
2877
- });
2878
-
2879
-
2880
- // Start the server
2881
- service.startServer();
2882
-
2883
- ```
2884
-
2667
+ ### ✨ Custom pages feature
2668
+ The **Custom pages** feature allows developers to code service-specific custom pages that exist outside the standard Express Services JSON configuration. More details at [Custom-pages.md](docs/Custom-pages.md)
2885
2669
 
2886
2670
  ### 🛣️ Routes
2887
2671
  The project uses express.js to serve the following routes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
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",
@@ -56,7 +56,7 @@
56
56
  "coverage:badge": "coverage-badges --output ./coverage-badges.svg && npm run coverage:copy"
57
57
  },
58
58
  "dependencies": {
59
- "@gov-cy/dsf-email-templates": "^2.1.1",
59
+ "@gov-cy/dsf-email-templates": "^2.1.11",
60
60
  "@gov-cy/govcy-frontend-renderer": "^1.26.0",
61
61
  "axios": "^1.9.0",
62
62
  "cookie-parser": "^1.4.7",
@@ -184,7 +184,7 @@ export function govcyReviewPostHandler() {
184
184
  const submissionData = prepareSubmissionData(req, siteId, service);
185
185
 
186
186
  // Prepare submission data for API
187
- const submissionDataAPI = prepareSubmissionDataAPI(submissionData);
187
+ const submissionDataAPI = prepareSubmissionDataAPI(submissionData, service);
188
188
 
189
189
  logger.debug("Prepared submission data for API:", submissionDataAPI);
190
190
 
@@ -133,7 +133,7 @@ export function populateFormData(
133
133
  // Delete link (preserve ?route=review if present)
134
134
  element.params.deleteHref = `${basePath}/delete-file/${fieldName}${(routeParam) ? `?route=${routeParam}` : ''}`
135
135
 
136
-
136
+
137
137
  } else {
138
138
  // TODO: Ask Andreas how to handle empty file inputs
139
139
  element.params.value = "";
@@ -295,3 +295,74 @@ export function getFormData(elements, formData, store = {}, siteId = "", pageUrl
295
295
 
296
296
  return filteredData;
297
297
  }
298
+
299
+ /**
300
+ * Get empty form data for multipleThings elements.
301
+ * Used to fill in empty multipleThings pages with the correct structure
302
+ *
303
+ * @param {object|Array} pageOrElements The page or elements of a conditional radio
304
+ * @param {object} emptyObject The object to populate with empty values
305
+ * @returns {object} An object with empty values for each form element
306
+ */
307
+ export function getMultipleThingsEmptyFormData(pageOrElements, emptyObject = {}) {
308
+ // Determine if we're given a full page or just an array of elements
309
+ let elements = [];
310
+ if (Array.isArray(pageOrElements)) {
311
+ elements = pageOrElements; // recursion case
312
+ } else if (pageOrElements?.pageTemplate?.sections) {
313
+ // Deep copy to avoid modifying the original
314
+ const pageTemplateCopy = JSON.parse(JSON.stringify(pageOrElements.pageTemplate));
315
+ // Find the first form element in sections
316
+ for (const section of pageTemplateCopy.sections) {
317
+ const form = section.elements?.find(el => el.element === "form");
318
+ if (form) {
319
+ elements = form?.params?.elements || [];
320
+ break;
321
+ }
322
+ }
323
+ } else {
324
+ // No valid elements
325
+ return emptyObject;
326
+ }
327
+
328
+ // Iterate through elements in order (like getFormData)
329
+ elements.forEach(element => {
330
+ const { name } = element.params || {};
331
+
332
+ if (ALLOWED_FORM_ELEMENTS.includes(element.element) && name) {
333
+ // Handle different element types
334
+ if (["checkboxes", "radios", "select"].includes(element.element)) {
335
+ emptyObject[name] = "";
336
+
337
+ // Handle conditional radios (same recursion pattern as getFormData)
338
+ if (element.element === "radios" && Array.isArray(element.params?.items)) {
339
+ element.params.items.forEach(item => {
340
+ if (item.conditionalElements) {
341
+ Object.assign(
342
+ emptyObject,
343
+ getMultipleThingsEmptyFormData(item.conditionalElements, {})
344
+ );
345
+ }
346
+ });
347
+ }
348
+ }
349
+
350
+ // Handle dateInput (single name, per your clarification)
351
+ else if (element.element === "dateInput") {
352
+ emptyObject[name] = "";
353
+ }
354
+
355
+ // Handle fileInput (suffix Attachment, per submission schema)
356
+ else if (element.element === "fileInput") {
357
+ emptyObject[`${name}Attachment`] = "";
358
+ }
359
+
360
+ // Handle all other elements (textInput, textArea, datePicker, etc.)
361
+ else {
362
+ emptyObject[name] = "";
363
+ }
364
+ }
365
+ });
366
+
367
+ return emptyObject;
368
+ }
@@ -4,7 +4,8 @@ import * as dataLayer from "./govcyDataLayer.mjs";
4
4
  import { DSFEmailRenderer } from '@gov-cy/dsf-email-templates';
5
5
  import { ALLOWED_FORM_ELEMENTS } from "./govcyConstants.mjs";
6
6
  import { evaluatePageConditions } from "./govcyExpressions.mjs";
7
- import { getPageConfigData } from "./govcyLoadConfigData.mjs";
7
+ import { getServiceConfigData, getPageConfigData } from "./govcyLoadConfigData.mjs";
8
+ import { getMultipleThingsEmptyFormData } from "./govcyFormHandling.mjs";
8
9
  import { logger } from "./govcyLogger.mjs";
9
10
  import { createUmdManualPageTemplate } from "../middleware/govcyUpdateMyDetails.mjs"
10
11
  import nunjucks from "nunjucks";
@@ -252,21 +253,48 @@ export function prepareSubmissionData(req, siteId, service) {
252
253
  /**
253
254
  * Prepares the submission data for the API, stringifying all relevant fields.
254
255
  *
255
- * @param {object} data data prepared by `prepareSubmissionData`
256
+ * @param {object} data data prepared by `prepareSubmissionData`\
257
+ * @param {object} service service config data
256
258
  * @returns {object} The API-ready submission data object with all fields as strings
257
259
  */
258
- export function prepareSubmissionDataAPI(data) {
260
+ export function prepareSubmissionDataAPI(data, service) {
261
+
262
+ //deep copy data to avoid mutating the original
263
+ let dataObj = JSON.parse(JSON.stringify(data));
264
+ // get site?.submissionAPIEndpoint.isDSFSubmissionPlatform
265
+ const isDSFSubmissionPlatform = service?.site?.usesDSFSubmissionPlatform || false;
266
+
267
+ // If DSF Submission Platform, ensure submissionData is an array
268
+ // this is intended for multipleThings pages only
269
+ if (isDSFSubmissionPlatform) {
270
+ // loop through submissionData
271
+ for (const [key, value] of Object.entries(dataObj.submissionData || {})) {
272
+ // check if the value is an empty array
273
+ if (Array.isArray(value) && value.length === 0) {
274
+ // get the pageConfigData for the page
275
+ try {
276
+ const page = getPageConfigData(service, key);
277
+ let pageEmptyData = getMultipleThingsEmptyFormData(page);
278
+ //replace the dataObj.submissionData[key] with the empty data
279
+ dataObj.submissionData[key] = [pageEmptyData];
280
+
281
+ } catch (error) {
282
+ logger.error('Error getting pageConfigData:', key);
283
+ }
284
+ }
285
+ }
286
+ }
259
287
 
260
288
  return {
261
- submissionUsername: String(data.submissionUsername ?? ""),
262
- submissionEmail: String(data.submissionEmail ?? ""),
263
- submissionData: JSON.stringify(data.submissionData ?? {}),
264
- submissionDataVersion: String(data.submissionDataVersion ?? ""),
265
- printFriendlyData: JSON.stringify(data.printFriendlyData ?? []),
266
- rendererData: JSON.stringify(data.rendererData ?? {}),
267
- rendererVersion: String(data.rendererVersion ?? ""),
268
- designSystemsVersion: String(data.designSystemsVersion ?? ""),
269
- service: JSON.stringify(data.service ?? {})
289
+ submissionUsername: String(dataObj.submissionUsername ?? ""),
290
+ submissionEmail: String(dataObj.submissionEmail ?? ""),
291
+ submissionData: JSON.stringify(dataObj.submissionData ?? {}),
292
+ submissionDataVersion: String(dataObj.submissionDataVersion ?? ""),
293
+ printFriendlyData: JSON.stringify(dataObj.printFriendlyData ?? []),
294
+ rendererData: JSON.stringify(dataObj.rendererData ?? {}),
295
+ rendererVersion: String(dataObj.rendererVersion ?? ""),
296
+ designSystemsVersion: String(dataObj.designSystemsVersion ?? ""),
297
+ service: JSON.stringify(dataObj.service ?? {})
270
298
  };
271
299
  }
272
300
 
@@ -28,6 +28,7 @@ function validateValue(value, rules) {
28
28
  alpha: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff\s]+$/.test(val),
29
29
  alphaNum: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff0-9\s]+$/.test(val),
30
30
  noSpecialChars: (val) => /^([0-9]|[A-Z]|[a-z]|[α-ω]|[Α-Ω]|[,]|[.]|[-]|[(]|[)]|[?]|[!]|[;]|[:]|[\n]|[\r]|[ _]|[\u0370-\u03ff\u1f00-\u1fff])+$/.test(val),
31
+ noSpecialCharsEl: (val) => /^([0-9]|[α-ω]|[Α-Ω]|[,]|[.]|[-]|[(]|[)]|[?]|[!]|[;]|[:]|[\n]|[\r]|[ _]|[\u0370-\u03ff\u1f00-\u1fff])+$/.test(val),
31
32
  name: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff\s'-]+$/.test(val),
32
33
  tel: (val) => /^(?:\+|00)?[\d\s\-()]{8,20}$/.test(val.replace(/[\s\-()]/g, '')),
33
34
  mobile: (val) => /^(?:\+|00)?[\d\s\-()]{8,20}$/.test(val.replace(/[\s\-()]/g, '')),