@gov-cy/govcy-express-services 1.0.0-alpha.17 → 1.0.0-alpha.19

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
@@ -326,6 +326,13 @@ Here is an example JSON config:
326
326
  "serviceId": "TEST_SUBMISSION_API_SERVIVE_ID",
327
327
  "dsfgtwApiKey": "TEST_SUBMISSION_DSF_GTW_KEY"
328
328
  },
329
+ "fileDeleteAPIEndpoint": { //<-- File delete API endpoint
330
+ "url": "TEST_DELETE_FILE_API_URL",
331
+ "method": "DELETE",
332
+ "clientKey": "TEST_SUBMISSION_API_CLIENT_KEY",
333
+ "serviceId": "TEST_SUBMISSION_API_SERVIVE_ID",
334
+ "dsfgtwApiKey": "TEST_SUBMISSION_DSF_GTW_KEY"
335
+ },
329
336
  "eligibilityAPIEndpoints": [ //<-- Eligibility API endpoints
330
337
  {
331
338
  "url": "TEST_ELIGIBILITY_2_API_URL",
@@ -696,6 +703,7 @@ Here are some details explaining the JSON structure:
696
703
  - `submissionPutAPIEndpoint`: The submission put API endpoint, to be used for temporary saving the submission data. See more on the [temporary save feature](#-temporary-save-feature) section below.
697
704
  - `fileUploadAPIEndpoint`: The file upload API endpoint, to be used for uploading files. See more on the [file upload feature](#%EF%B8%8F-files-uploads-feature) section below.
698
705
  - `fileDownloadAPIEndpoint`: The file download API endpoint, to be used for downloading files. See more on the [file upload feature](#%EF%B8%8F-files-uploads-feature) section below.
706
+ - `fileDeleteAPIEndpoint`: The file delete API endpoint, to be used for deleting files. See more on the [file upload feature](#%EF%B8%8F-files-uploads-feature) section below.
699
707
  - `pages` array: An array of page objects, each representing a page in the site.
700
708
  - `pageData` object: Contains the metadata to be rendered on the page. See [govcy-frontend-renderer](https://github.com/gov-cy/govcy-frontend-renderer/tree/main#site-and-page-meta-data-explained) for more details
701
709
  - `nextPage`: The URL of the next page to be rendered after the user clicks the `continue` button.
@@ -1263,7 +1271,7 @@ The data is collected from the form elements and the data layer and are sent via
1263
1271
  "submissionDataVersion": "0.1", // Submission data version
1264
1272
  "submissionData": { // Submission raw data. Object, will be stringified
1265
1273
  "index": { // Page level
1266
- "id_select": ["id", "arc"], // field level. Could be string or array
1274
+ "id_select": ["id", "arc"], // field level: checkboxes are ALWAYS arrays (may be []); radios/select/text are strings
1267
1275
  "id_number": "654654",
1268
1276
  "arc_number": "",
1269
1277
  "aka": "232323",
@@ -1516,8 +1524,8 @@ The data is collected from the form elements and the data layer and are sent via
1516
1524
  "el": "Ταυτοποίηση",
1517
1525
  "en": "Identification"
1518
1526
  },
1519
- "value": ["id", "arc"], // Field value. Could be string or array
1520
- "valueLabel": [ // Field value label. Could be string or array
1527
+ "value": ["id", "arc"], // Field value. // field level: checkboxes are ALWAYS arrays (may be []); radios/select/text are strings
1528
+ "valueLabel": [ // Field value label
1521
1529
  {
1522
1530
  "el": "Ταυτότητα",
1523
1531
  "en": "ID",
@@ -2168,7 +2176,7 @@ The [💾 Temporary save feature](#-temporary-save-feature) must be enabled for
2168
2176
 
2169
2177
 
2170
2178
  #### How to enable and configure file uploads
2171
- To use this feature, configure the config JSON file. In your service’s `site` object, add both a `fileUploadAPIEndpoint` and `fileDownloadAPIEndpoint` entry:
2179
+ To use this feature, configure the config JSON file. In your service’s `site` object, add a `fileUploadAPIEndpoint`, `fileDownloadAPIEndpoint`and `fileDeleteAPIEndpoint` entry:
2172
2180
 
2173
2181
  ```json
2174
2182
  "fileUploadAPIEndpoint": {
@@ -2182,6 +2190,12 @@ To use this feature, configure the config JSON file. In your service’s `site`
2182
2190
  "method": "GET",
2183
2191
  "clientKey": "TEST_SUBMISSION_API_CLIENT_KEY",
2184
2192
  "serviceId": "TEST_SUBMISSION_API_SERVIVE_ID"
2193
+ },
2194
+ "fileDeleteAPIEndpoint": {
2195
+ "url": "TEST_DELETE_FILE_API_URL",
2196
+ "method": "DELETE",
2197
+ "clientKey": "TEST_SUBMISSION_API_CLIENT_KEY",
2198
+ "serviceId": "TEST_SUBMISSION_API_SERVIVE_ID"
2185
2199
  }
2186
2200
  ```
2187
2201
 
@@ -2301,7 +2315,10 @@ Here is a sample code section of a page definition with a file input field:
2301
2315
  - **When clinking `view`**:
2302
2316
  - The file is downloaded using the `fileDownloadAPIEndpoint` and opened in a new tab.
2303
2317
  - **When clinking `delete`**:
2304
- - A confimation page is displayed asking the user to confirm the deletion. If the user confirms, the file is deleted from the data layer and the `fileView` element is removed from the page.
2318
+ - A confimation page is displayed asking the user to confirm the deletion. If the user confirms (clicks `Yes`):
2319
+ - The file is deleted using the `fileDeleteAPIEndpoint`
2320
+ - The file is deleted from the data layer and the `fileView` element is removed from the page.
2321
+ - If the same file is used on another page (with the same `fileId` and `sha256`), they are also removed from the data layer
2305
2322
  - **On the `review` page after upload**:
2306
2323
  - The element is displayed with a link to `View file`. A `Change` link is also displayed for the whole page.
2307
2324
  - **On the `success` page and email after upload**:
@@ -2441,6 +2458,65 @@ HTTP/1.1 404 Not Found
2441
2458
  }
2442
2459
  ```
2443
2460
 
2461
+ #### `fileDeleteAPIEndpoint` `DELETE` API Request and Response
2462
+ This API is used to delete the file uploaded by the user. It returns the file data in Base64.
2463
+ > ⚠️ **Important note:**
2464
+ > If the same file (same `fileId` and `sha256`) is used for other fields in the same application for the same service and the same user, when deleted it will be removed from all instances in the data layer. A warning will appear in the user's delete confirmation page to warn the users in such cases.
2465
+
2466
+ **Request:**
2467
+
2468
+ - **HTTP Method**: DELETE
2469
+ - **URL**: Resolved from the url property in your config (from the environment variable) concatenated with `/:fileid/:sha256`. For example `https://example.com/api/fileDelete/123456789123456/12345678901234567890123`:
2470
+ - `fileid` is the `fileId` of the file uploaded by the user.
2471
+ - `sha256` is the `sha256` of the file uploaded by the user.
2472
+ - **Headers**:
2473
+ - **Authorization**: `Bearer <access_token>` (form user's cyLogin access token)
2474
+ - **client-key**: `<clientKey>` (from config/env)
2475
+ - **service-id**: `<serviceId>` (from config/env)
2476
+ - **Accept**: `text/plain`
2477
+
2478
+ **Example Request:**
2479
+
2480
+ ```http
2481
+ DELETE fileDelete/123456789123456/12345678901234567890123 HTTP/1.1
2482
+ Host: localhost:3002
2483
+ Authorization: Bearer eyJhbGciOi...
2484
+ client-key: 12345678901234567890123456789000
2485
+ service-id: 123
2486
+ Accept: text/plain
2487
+ Content-Type: application/json
2488
+ ```
2489
+
2490
+ **Response:**
2491
+
2492
+ The API is expected to return a JSON response with the following structure:
2493
+
2494
+ **When file is found and deleted:**
2495
+
2496
+ ```http
2497
+ HTTP/1.1 200 OK
2498
+
2499
+ {
2500
+ "ErrorCode": 0,
2501
+ "ErrorMessage": null,
2502
+ "Succeeded": true
2503
+ }
2504
+ ```
2505
+
2506
+ **When file is NOT found:**
2507
+
2508
+ ```http
2509
+ HTTP/1.1 404 Not Found
2510
+
2511
+ {
2512
+ "ErrorCode": 404,
2513
+ "ErrorMessage": "File not found",
2514
+ "InformationMessage": null,
2515
+ "Data": null,
2516
+ "Succeeded": false
2517
+ }
2518
+ ```
2519
+
2444
2520
  #### File uploads in the data layer
2445
2521
  The system uses the `fileId` and `sha256` to identify the file uploaded by the user. The file information are stored in the data layer in the following format:
2446
2522
 
@@ -2566,6 +2642,7 @@ TEST_SUBMISSION_PUT_API_URL=http://localhost:3002/save
2566
2642
  # Optional File Upload and download endpoints (test service)
2567
2643
  TEST_FILE_UPLOAD_API_URL=http://localhost:3002/fileUpload
2568
2644
  TEST_FILE_DOWNLOAD_API_URL=http://localhost:3002/fileDownload
2645
+ TEST_FILE_DELETE_API_URL=http://localhost:3002/fileDelete
2569
2646
 
2570
2647
  # Eligibility checks (optional test APIs)
2571
2648
  TEST_ELIGIBILITY_1_API_URL=http://localhost:3002/eligibility1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.19",
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",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@gov-cy/dsf-email-templates": "^2.1.0",
54
- "@gov-cy/govcy-frontend-renderer": "^1.22.1",
54
+ "@gov-cy/govcy-frontend-renderer": "^1.23.0",
55
55
  "axios": "^1.9.0",
56
56
  "cookie-parser": "^1.4.7",
57
57
  "dotenv": "^16.3.1",
@@ -2,10 +2,11 @@ import * as govcyResources from "../resources/govcyResources.mjs";
2
2
  import * as dataLayer from "../utils/govcyDataLayer.mjs";
3
3
  import { logger } from "../utils/govcyLogger.mjs";
4
4
  import { pageContainsFileInput } from "../utils/govcyHandleFiles.mjs";
5
- import { whatsIsMyEnvironment } from '../utils/govcyEnvVariables.mjs';
5
+ import { whatsIsMyEnvironment, getEnvVariable, getEnvVariableBool } from '../utils/govcyEnvVariables.mjs';
6
6
  import { handleMiddlewareError } from "../utils/govcyUtils.mjs";
7
7
  import { getPageConfigData } from "../utils/govcyLoadConfigData.mjs";
8
8
  import { evaluatePageConditions } from "../utils/govcyExpressions.mjs";
9
+ import { govcyApiRequest } from "../utils/govcyApiRequest.mjs";
9
10
  import { URL } from "url";
10
11
 
11
12
 
@@ -50,7 +51,7 @@ export function govcyFileDeletePageHandler() {
50
51
  const elementData = dataLayer.getFormDataValue(req.session, siteId, pageUrl, elementName)
51
52
 
52
53
  // If the element data is not found, return an error response
53
- if (!elementData) {
54
+ if (!elementData || !elementData?.sha256 || !elementData?.fileId) {
54
55
  return handleMiddlewareError(`File input [${elementName}] data not found on this page`, 404, next);
55
56
  }
56
57
 
@@ -80,6 +81,20 @@ export function govcyFileDeletePageHandler() {
80
81
  ]
81
82
  };
82
83
 
84
+ //contruct the warning if the file was uploaded more than once
85
+ const warningSameFile = {
86
+ element: "warning",
87
+ params: {
88
+ text: govcyResources.staticResources.text.deleteSameFileWarning,
89
+ }
90
+ };
91
+
92
+ const showSameFileWarning = dataLayer.isFileUsedInSiteInputDataAgain(
93
+ req.session,
94
+ siteId,
95
+ elementData
96
+ );
97
+
83
98
  // Construct page title
84
99
  const pageRadios = {
85
100
  element: "radios",
@@ -88,7 +103,8 @@ export function govcyFileDeletePageHandler() {
88
103
  name: "deleteFile",
89
104
  legend: pageTitle,
90
105
  isPageHeading: true,
91
- classes: "govcy-mb-6",
106
+ classes: "govcy-mb-6",// only include the warning block when the file is referenced >1 times
107
+ elements: showSameFileWarning ? [warningSameFile] : [],
92
108
  items: [
93
109
  {
94
110
  value: "yes",
@@ -171,7 +187,7 @@ export function govcyFileDeletePageHandler() {
171
187
  * This middleware processes the post, validates the form and handles the file data layer
172
188
  */
173
189
  export function govcyFileDeletePostHandler() {
174
- return (req, res, next) => {
190
+ return async (req, res, next) => {
175
191
  try {
176
192
  // Extract siteId and pageUrl from request
177
193
  let { siteId, pageUrl, elementName } = req.params;
@@ -198,6 +214,15 @@ export function govcyFileDeletePostHandler() {
198
214
  if (!fileInputElement) {
199
215
  return handleMiddlewareError(`File input [${elementName}] not allowed on this page`, 404, next);
200
216
  }
217
+
218
+ //get element data
219
+ const elementData = dataLayer.getFormDataValue(req.session, siteId, pageUrl, elementName)
220
+
221
+ // If the element data is not found, return an error response
222
+ if (!elementData || !elementData?.sha256 || !elementData?.fileId) {
223
+ return handleMiddlewareError(`File input [${elementName}] data not found on this page`, 404, next);
224
+ }
225
+
201
226
  // the page base return url
202
227
  const pageBaseReturnUrl = `http://localhost:3000/${siteId}/${pageUrl}`;
203
228
 
@@ -220,7 +245,62 @@ export function govcyFileDeletePostHandler() {
220
245
 
221
246
  //if no validation errors
222
247
  if (req.body.deleteFile === "yes") {
223
- dataLayer.storePageDataElement(req.session, siteId, pageUrl, elementName, "");
248
+ // Try to delete the file via the delete API.
249
+ // If it fails, log the error but continue to remove the file from the session
250
+ try {
251
+ // Get the delete file configuration
252
+ const deleteCfg = serviceCopy?.site?.fileDeleteAPIEndpoint;
253
+ // Check if download file configuration is available
254
+ if (!deleteCfg?.url || !deleteCfg?.clientKey || !deleteCfg?.serviceId) {
255
+ return handleMiddlewareError(`File delete APU configuration not found`, 404, next);
256
+ }
257
+
258
+ // Environment vars
259
+ const allowSelfSignedCerts = getEnvVariableBool("ALLOW_SELF_SIGNED_CERTIFICATES", false);
260
+ let url = getEnvVariable(deleteCfg.url || "", false);
261
+ const clientKey = getEnvVariable(deleteCfg.clientKey || "", false);
262
+ const serviceId = getEnvVariable(deleteCfg.serviceId || "", false);
263
+ const dsfGtwKey = getEnvVariable(deleteCfg?.dsfgtwApiKey || "", "");
264
+ const method = (deleteCfg?.method || "GET").toLowerCase();
265
+
266
+ // Check if the upload API is configured correctly
267
+ if (!url || !clientKey) {
268
+ return handleMiddlewareError(`Missing environment variables for upload`, 404, next);
269
+ }
270
+
271
+ // Construct the URL with tag being the elementName
272
+ url += `/${encodeURIComponent(elementData.fileId)}/${encodeURIComponent(elementData.sha256)}`;
273
+
274
+ // Get the user
275
+ const user = dataLayer.getUser(req.session);
276
+ // Perform the delete request
277
+ const response = await govcyApiRequest(
278
+ method,
279
+ url,
280
+ {},
281
+ true,
282
+ user,
283
+ {
284
+ accept: "text/plain",
285
+ "client-key": clientKey,
286
+ "service-id": serviceId,
287
+ ...(dsfGtwKey !== "" && { "dsfgtw-api-key": dsfGtwKey })
288
+ },
289
+ 3,
290
+ allowSelfSignedCerts
291
+ );
292
+
293
+ // If not succeeded, handle error
294
+ if (!response?.Succeeded) {
295
+ logger.error("fileDeleteAPIEndpoint returned succeeded false");
296
+ }
297
+
298
+ } catch (error) {
299
+ logger.error(`fileDeleteAPIEndpoint Call failed: ${error.message}`);
300
+ }
301
+ // if succeeded all good
302
+ // dataLayer.storePageDataElement(req.session, siteId, pageUrl, elementName, "");
303
+ dataLayer.removeAllFilesFromSite(req.session, siteId, { fileId: elementData.fileId, sha256: elementData.sha256 });
224
304
  logger.info(`File deleted by user`, { siteId, pageUrl, elementName });
225
305
  }
226
306
  // construct the page url
@@ -233,6 +313,7 @@ export function govcyFileDeletePostHandler() {
233
313
  // redirect to the page (relative path)
234
314
  res.redirect(myUrl.pathname + myUrl.search);
235
315
  } catch (error) {
316
+ logger.error("Error in govcyFileDeletePostHandler middleware:", error.message);
236
317
  return next(error); // Pass error to govcyHttpErrorHandler
237
318
  }
238
319
  };
@@ -123,7 +123,7 @@ export const staticResources = {
123
123
  },
124
124
  deleteFileTitle : {
125
125
  en: "Are you sure you want to delete the file \"{{file}}\"? ",
126
- el: "Είστε σίγουροι ότι θέλετε να διαγράψετε το αρχείο \"{{file}}\";",
126
+ el: "Σίγουρα θέλετε να διαγράψετε το αρχείο \"{{file}}\";",
127
127
  tr: "Are you sure you want to delete the file \"{{file}}\"? "
128
128
  },
129
129
  deleteYesOption: {
@@ -145,6 +145,11 @@ export const staticResources = {
145
145
  en: "View file",
146
146
  el: "Προβολή αρχείου",
147
147
  tr: "View file"
148
+ },
149
+ deleteSameFileWarning: {
150
+ en: "Υou have uploaded the same file more than once in this application. If you delete it, it will be deleted from all places in the application.",
151
+ el: "Έχετε ανεβάσει το αρχείο αυτό και σε άλλα σημεία της αίτησης. Αν το διαγράψετε, θα διαγραφεί από όλα τα σημεία.",
152
+ tr: "Υou have uploaded the same file more than once in this application. If you delete it, it will be deleted from all places in the application."
148
153
  }
149
154
  },
150
155
  //remderer sections
@@ -102,7 +102,7 @@ export function storePageData(store, siteId, pageUrl, formData) {
102
102
  export function storePageDataElement(store, siteId, pageUrl, elementName, value) {
103
103
  // Ensure session structure is initialized
104
104
  initializeSiteData(store, siteId, pageUrl);
105
-
105
+
106
106
  // Store the element value
107
107
  store.siteData[siteId].inputData[pageUrl].formData[elementName] = value;
108
108
  }
@@ -191,11 +191,11 @@ export function storeSiteSubmissionData(store, siteId, submissionData) {
191
191
  // let rawData = getSiteInputData(store, siteId);
192
192
  // Store the submission data
193
193
  store.siteData[siteId].submissionData = submissionData;
194
-
194
+
195
195
  // Clear validation errors from the session
196
196
  store.siteData[siteId].inputData = {};
197
197
  // Clear presaved/temporary save data
198
- store.siteData[siteId].loadData = {};
198
+ store.siteData[siteId].loadData = {};
199
199
 
200
200
  }
201
201
 
@@ -208,7 +208,7 @@ export function storeSiteSubmissionData(store, siteId, submissionData) {
208
208
  * @param {object} result - API response
209
209
  */
210
210
  export function storeSiteEligibilityResult(store, siteId, endpointKey, result) {
211
-
211
+
212
212
  initializeSiteData(store, siteId); // Ensure the structure exists
213
213
 
214
214
  if (!store.siteData[siteId].eligibility) store.siteData[siteId].eligibility = {};
@@ -244,7 +244,7 @@ export function getSiteEligibilityResult(store, siteId, endpointKey, maxAgeMs =
244
244
  */
245
245
  export function getPageValidationErrors(store, siteId, pageUrl) {
246
246
  const validationErrors = store?.siteData?.[siteId]?.inputData?.[pageUrl]?.validationErrors || null;
247
-
247
+
248
248
  if (validationErrors) {
249
249
  // Clear validation errors from the session
250
250
  delete store.siteData[siteId].inputData[pageUrl].validationErrors;
@@ -275,7 +275,7 @@ export function getPageData(store, siteId, pageUrl) {
275
275
  */
276
276
  export function getSiteSubmissionErrors(store, siteId) {
277
277
  const validationErrors = store?.siteData?.[siteId]?.submissionErrors || null;
278
-
278
+
279
279
  if (validationErrors) {
280
280
  // Clear validation errors from the session
281
281
  delete store.siteData[siteId].submissionErrors;
@@ -294,7 +294,7 @@ export function getSiteSubmissionErrors(store, siteId) {
294
294
  */
295
295
  export function getSiteData(store, siteId) {
296
296
  const inputData = store?.siteData?.[siteId] || {};
297
-
297
+
298
298
  if (inputData) {
299
299
  return inputData;
300
300
  }
@@ -311,7 +311,7 @@ export function getSiteData(store, siteId) {
311
311
  */
312
312
  export function getSiteInputData(store, siteId) {
313
313
  const inputData = store?.siteData?.[siteId]?.inputData || {};
314
-
314
+
315
315
  if (inputData) {
316
316
  return inputData;
317
317
  }
@@ -328,7 +328,7 @@ export function getSiteInputData(store, siteId) {
328
328
  */
329
329
  export function getSiteLoadData(store, siteId) {
330
330
  const loadData = store?.siteData?.[siteId]?.loadData || {};
331
-
331
+
332
332
  if (loadData) {
333
333
  return loadData;
334
334
  }
@@ -344,9 +344,9 @@ export function getSiteLoadData(store, siteId) {
344
344
  * @returns {string|null} The reference number or null if not available
345
345
  */
346
346
  export function getSiteLoadDataReferenceNumber(store, siteId) {
347
- const ref = store?.siteData?.[siteId]?.loadData?.referenceValue;
347
+ const ref = store?.siteData?.[siteId]?.loadData?.referenceValue;
348
348
 
349
- return typeof ref === 'string' && ref.trim() !== '' ? ref : null;
349
+ return typeof ref === 'string' && ref.trim() !== '' ? ref : null;
350
350
  }
351
351
 
352
352
 
@@ -359,9 +359,9 @@ export function getSiteLoadDataReferenceNumber(store, siteId) {
359
359
  */
360
360
  export function getSiteSubmissionData(store, siteId) {
361
361
  initializeSiteData(store, siteId); // Ensure the structure exists
362
-
362
+
363
363
  const submission = store?.siteData?.[siteId]?.submissionData || {};
364
-
364
+
365
365
  if (submission) {
366
366
  return submission;
367
367
  }
@@ -388,7 +388,7 @@ export function getFormDataValue(store, siteId, pageUrl, elementName) {
388
388
  * @param {object} store The session store
389
389
  * @returns The user object from the store or null if it doesn't exist.
390
390
  */
391
- export function getUser(store){
391
+ export function getUser(store) {
392
392
  return store.user || null;
393
393
  }
394
394
 
@@ -396,3 +396,181 @@ export function clearSiteData(store, siteId) {
396
396
  delete store?.siteData[siteId];
397
397
  }
398
398
 
399
+ /**
400
+ * Check if a file reference is used in more than one place (field) across the site's inputData.
401
+ *
402
+ * A "file reference" is an object like:
403
+ * { sha256: "abc...", fileId: "xyz..." }
404
+ *
405
+ * Matching rules:
406
+ * - If both fileId and sha256 are provided, both must match.
407
+ * - If only one is provided, we match by that single property.
408
+ *
409
+ * Notes:
410
+ * - Does NOT mutate the session.
411
+ * - Safely handles missing site/pages.
412
+ * - If a form field is an array (e.g., multiple file inputs), each item is checked.
413
+ *
414
+ * @param {object} store The session object (e.g., req.session)
415
+ * @param {string} siteId The site identifier
416
+ * @param {object} params { fileId?: string, sha256?: string }
417
+ * @returns {boolean} true if the file is found in more than one place, else false
418
+ */
419
+ export function isFileUsedInSiteInputDataAgain(store, siteId, { fileId, sha256 } = {}) {
420
+ // If neither identifier is provided, we cannot match anything
421
+ if (!fileId && !sha256) return false;
422
+
423
+ // Ensure session structure is initialized
424
+ initializeSiteData(store, siteId);
425
+
426
+ // Site input data: session.siteData[siteId].inputData
427
+ const site = store?.siteData?.[siteId]?.inputData;
428
+ if (!site || typeof site !== 'object') return false;
429
+
430
+ let hits = 0; // how many fields across the site reference this file
431
+
432
+ // Loop all pages under the site
433
+ for (const pageKey of Object.keys(site)) {
434
+ const formData = site[pageKey]?.formData;
435
+ if (!formData || typeof formData !== 'object') continue;
436
+
437
+ // Loop all fields on the page
438
+ for (const [_, value] of Object.entries(formData)) {
439
+ if (value == null) continue;
440
+
441
+ // Normalize to an array to also support multi-value fields (e.g., multiple file inputs)
442
+ const candidates = Array.isArray(value) ? value : [value];
443
+
444
+ for (const candidate of candidates) {
445
+ // We only consider objects that look like file references
446
+ if (
447
+ candidate &&
448
+ typeof candidate === 'object' &&
449
+ 'fileId' in candidate &&
450
+ 'sha256' in candidate
451
+ ) {
452
+ const idMatches = fileId ? candidate.fileId === fileId : true;
453
+ const shaMatches = sha256 ? candidate.sha256 === sha256 : true;
454
+
455
+ if (idMatches && shaMatches) {
456
+ hits += 1;
457
+ // As soon as we see it in more than one place, we can answer true
458
+ if (hits > 1) return true;
459
+ }
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ // If we get here, it was used 0 or 1 times
466
+ return false;
467
+ }
468
+
469
+
470
+ /**
471
+ * Remove (replace with "") file values across ALL pages of a site,
472
+ * matching a specific fileId and/or sha256.
473
+ *
474
+ * Matching rules:
475
+ * - If BOTH fileId and sha256 are provided, a file object must match BOTH.
476
+ * - If ONLY fileId is provided, match on fileId.
477
+ * - If ONLY sha256 is provided, match on sha256.
478
+ *
479
+ * Scope:
480
+ * - Operates on every page under store.siteData[siteId].inputData.
481
+ * - Shallow traversal of formData:
482
+ * • Direct fields: formData[elementId] = { fileId, sha256, ... }
483
+ * • Arrays: [ {fileId...}, {sha256...}, "other" ] → ["", "", "other"] (for matches)
484
+ *
485
+ * Side effects:
486
+ * - Mutates store.siteData[siteId].inputData[*].formData in place.
487
+ * - Intentionally returns nothing.
488
+ *
489
+ * @param {object} store - The data-layer store.
490
+ * @param {string} siteId - The site key under store.siteData to modify.
491
+ * @param {{ fileId?: string|null, sha256?: string|null }} match - Identifiers to match.
492
+ * Provide at least one of fileId/sha256. If both are given, both must match.
493
+ */
494
+ export function removeAllFilesFromSite(
495
+ store,
496
+ siteId,
497
+ { fileId = null, sha256 = null } = {}
498
+ ) {
499
+ // Ensure session structure is initialized
500
+ initializeSiteData(store, siteId);
501
+ // --- Guard rails ---------------------------------------------------------
502
+
503
+ // Nothing to remove if neither identifier is provided.
504
+ if (!fileId && !sha256) return;
505
+
506
+ // Per your structure: dig under .inputData for the site's pages.
507
+ const site = store?.siteData?.[siteId]?.inputData;
508
+ if (!site || typeof site !== "object") return;
509
+
510
+ // --- Helpers -------------------------------------------------------------
511
+
512
+ // Is this value a "file-like" object (has fileId and/or sha256)?
513
+ const isFileLike = (v) =>
514
+ v &&
515
+ typeof v === "object" &&
516
+ (Object.prototype.hasOwnProperty.call(v, "fileId") ||
517
+ Object.prototype.hasOwnProperty.call(v, "sha256"));
518
+
519
+ // Does a file-like object match the provided criteria?
520
+ const isMatch = (obj) => {
521
+ if (!isFileLike(obj)) return false;
522
+
523
+ // Strict when both are given
524
+ if (fileId && sha256) {
525
+ return obj.fileId === fileId && obj.sha256 === sha256;
526
+ }
527
+ // Otherwise match whichever was provided
528
+ if (fileId) return obj.fileId === fileId;
529
+ if (sha256) return obj.sha256 === sha256;
530
+
531
+ return false;
532
+ };
533
+
534
+ // --- Main traversal over all pages --------------------------------------
535
+
536
+ for (const page of Object.values(site)) {
537
+ // Each page should be an object with a formData object
538
+ const formData =
539
+ page &&
540
+ typeof page === "object" &&
541
+ page.formData &&
542
+ typeof page.formData === "object"
543
+ ? page.formData
544
+ : null;
545
+
546
+ if (!formData) continue; // skip content-only pages, etc.
547
+
548
+ // For each field on this page…
549
+ for (const key of Object.keys(formData)) {
550
+ const val = formData[key];
551
+
552
+ // Case A: a single file object → replace with "" if it matches.
553
+ if (isMatch(val)) {
554
+ formData[key] = "";
555
+ continue;
556
+ }
557
+
558
+ // Case B: an array → replace ONLY the matching items with "".
559
+ if (Array.isArray(val)) {
560
+ let changed = false;
561
+ const mapped = val.map((item) => {
562
+ if (isMatch(item)) {
563
+ changed = true;
564
+ return "";
565
+ }
566
+ return item;
567
+ });
568
+ if (changed) formData[key] = mapped;
569
+ }
570
+
571
+ // Note: If you later store file-like objects deeper in nested objects,
572
+ // add a recursive visitor here (with cycle protection / max depth).
573
+ }
574
+ }
575
+ }
576
+
@@ -17,7 +17,7 @@ import { logger } from "./govcyLogger.mjs";
17
17
  export function prepareSubmissionData(req, siteId, service) {
18
18
  // Get the raw data from the session store
19
19
  // const rawData = dataLayer.getSiteInputData(req.session, siteId);
20
-
20
+
21
21
  // ----- Conditional logic comes here
22
22
  // Filter site input data based on active pages only
23
23
  // const rawData = {};
@@ -143,7 +143,7 @@ export function prepareSubmissionData(req, siteId, service) {
143
143
  * @returns {object} The API-ready submission data object with all fields as strings
144
144
  */
145
145
  export function prepareSubmissionDataAPI(data) {
146
-
146
+
147
147
  return {
148
148
  submissionUsername: String(data.submissionUsername ?? ""),
149
149
  submissionEmail: String(data.submissionEmail ?? ""),
@@ -232,7 +232,7 @@ export function preparePrintFriendlyData(req, siteId, service) {
232
232
  }
233
233
  }
234
234
 
235
- return submissionData ;
235
+ return submissionData;
236
236
  }
237
237
 
238
238
  //------------------------------- Helper Functions -------------------------------//
@@ -317,6 +317,19 @@ function getValue(formElement, pageUrl, req, siteId) {
317
317
  } else {
318
318
  value = dataLayer.getFormDataValue(req.session, siteId, pageUrl, formElement.params.name);
319
319
  }
320
+
321
+ // 🔁 Normalize checkboxes: always return an array
322
+ if (formElement.element === "checkboxes") {
323
+ // If no value, return empty array
324
+ if (value == null || value === "") return [];
325
+ // If already an array, return as-is (but strip empties just in case)
326
+ if (Array.isArray(value)) {
327
+ // Strip empties just in case
328
+ return value.filter(v => v != null && v !== "");
329
+ }
330
+ // Else single value, convert to array
331
+ return [String(value)];
332
+ }
320
333
  return value;
321
334
  }
322
335
 
@@ -460,10 +473,10 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
460
473
  {
461
474
  "element": "htmlElement",
462
475
  "params": {
463
- "text": {
464
- "en": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.en}<span class="govcy-visually-hidden"> ${key?.en || ""}</span></a>`,
465
- "el": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.el}<span class="govcy-visually-hidden"> ${key?.el || ""}</span></a>`,
466
- "tr": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.tr}<span class="govcy-visually-hidden"> ${key?.tr || ""}</span></a>`
476
+ "text": {
477
+ "en": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.en}<span class="govcy-visually-hidden"> ${key?.en || ""}</span></a>`,
478
+ "el": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.el}<span class="govcy-visually-hidden"> ${key?.el || ""}</span></a>`,
479
+ "tr": `<a href="/${siteId}/${pageUrl}/view-file/${elementName}" target="_blank">${govcyResources.staticResources.text.viewFile.tr}<span class="govcy-visually-hidden"> ${key?.tr || ""}</span></a>`
467
480
  }
468
481
  }
469
482
  }
@@ -471,7 +484,7 @@ export function generateReviewSummary(submissionData, req, siteId, showChangeLin
471
484
  };
472
485
  }
473
486
 
474
-
487
+
475
488
 
476
489
 
477
490
  // Loop through each page in the submission data
@@ -550,7 +563,7 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
550
563
  }
551
564
  );
552
565
  }
553
-
566
+
554
567
  // Add data title to the body
555
568
  body.push(
556
569
  {
@@ -568,7 +581,7 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
568
581
  body.push(
569
582
  {
570
583
  component: "bodyHeading",
571
- params: {"headingLevel":2},
584
+ params: { "headingLevel": 2 },
572
585
  body: govcyResources.getLocalizeContent(pageTitle, req.globalLang)
573
586
  }
574
587
  );
@@ -578,14 +591,14 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
578
591
  for (const field of fields) {
579
592
  const label = govcyResources.getLocalizeContent(field.label, req.globalLang);
580
593
  const valueLabel = getSubmissionValueLabelString(field.valueLabel, req.globalLang);
581
- dataUl.push({key: label, value: valueLabel});
594
+ dataUl.push({ key: label, value: valueLabel });
582
595
  }
583
596
  // add data to the body
584
597
  body.push(
585
- {
586
- component: "bodyKeyValue",
587
- params: {type:"ul", items: dataUl},
588
- });
598
+ {
599
+ component: "bodyKeyValue",
600
+ params: { type: "ul", items: dataUl },
601
+ });
589
602
 
590
603
  }
591
604
 
@@ -606,84 +619,84 @@ export function generateSubmitEmail(service, submissionData, submissionId, req)
606
619
 
607
620
 
608
621
 
609
- /*
622
+ /*
610
623
  {
611
- "bank-details": {
612
- "formData": {
613
- "AccountName": "asd",
614
- "Iban": "CY12 0020 0123 0000 0001 2345 6789",
615
- "Swift": "BANKCY2NXXX",
616
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
617
- }
618
- },
619
- "answer-bank-boc": {
620
- "formData": {
621
- "Objection": "Object",
622
- "country": "Azerbaijan",
623
- "ObjectionReason": "ObjectionReasonCode1",
624
- "ObjectionExplanation": "asdsa",
625
- "DepositsBOCAttachment": "",
626
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
627
- }
628
- },
629
- "bank-settlement": {
630
- "formData": {
631
- "ReceiveSettlementExplanation": "",
632
- "ReceiveSettlementDate_day": "",
633
- "ReceiveSettlementDate_month": "",
634
- "ReceiveSettlementDate_year": "",
635
- "ReceiveSettlement": "no",
636
- "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
637
- }
638
- }
624
+ "bank-details": {
625
+ "formData": {
626
+ "AccountName": "asd",
627
+ "Iban": "CY12 0020 0123 0000 0001 2345 6789",
628
+ "Swift": "BANKCY2NXXX",
629
+ "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
630
+ }
631
+ },
632
+ "answer-bank-boc": {
633
+ "formData": {
634
+ "Objection": "Object",
635
+ "country": "Azerbaijan",
636
+ "ObjectionReason": "ObjectionReasonCode1",
637
+ "ObjectionExplanation": "asdsa",
638
+ "DepositsBOCAttachment": "",
639
+ "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
640
+ }
641
+ },
642
+ "bank-settlement": {
643
+ "formData": {
644
+ "ReceiveSettlementExplanation": "",
645
+ "ReceiveSettlementDate_day": "",
646
+ "ReceiveSettlementDate_month": "",
647
+ "ReceiveSettlementDate_year": "",
648
+ "ReceiveSettlement": "no",
649
+ "_csrf": "sjknv79rxjgv0uggo0d5312vzgz37jsh"
650
+ }
651
+ }
639
652
  }
640
653
 
641
654
 
642
655
 
643
656
  [
644
- {
645
- pageUrl: "personal-details",
646
- pageTitle: { en: "Personal data", el: "Προσωπικά στοιχεία" }, // from pageData.title in correct language
647
- fields: [
648
- [
649
- {
650
- id: "firstName",
651
- label: { en: "First Name", el: "Όνομα" },
652
- value: "John", // The actual user input value
653
- valueLabel: { en: "John", el: "John" } // Same label as the value for text inputs
654
- },
655
- {
656
- id: "lastName",
657
- label: { en: "Last Name", el: "Επίθετο" },
658
- value: "Doe", // The actual user input value
659
- valueLabel: { en: "Doe", el: "Doe" } // Same label as the value for text inputs
660
- },
661
- {
662
- id: "gender",
663
- label: { en: "Gender", el: "Φύλο" },
664
- value: "m", // The actual value ("male")
665
- valueLabel: { en: "Male", el: "Άντρας" } // The corresponding label for "male"
666
- },
667
- {
668
- id: "languages",
669
- label: { en: "Languages", el: "Γλώσσες" },
670
- value: ["en", "el"], // The selected values ["en", "el"]
671
- valueLabel: [
672
- { en: "English", el: "Αγγλικά" }, // Labels corresponding to "en" and "el"
673
- { en: "Greek", el: "Ελληνικά" }
674
- ]
675
- },
676
- {
677
- id: "birthDate",
678
- label: { en: "Birth Date", el: "Ημερομηνία Γέννησης" },
679
- value: "1990-01-13", // The actual value based on user input
680
- valueLabel: "13/1/1990" // Date inputs label will be conveted to D/M/YYYY
681
- }
682
- ]
683
- },
684
- ...
657
+ {
658
+ pageUrl: "personal-details",
659
+ pageTitle: { en: "Personal data", el: "Προσωπικά στοιχεία" }, // from pageData.title in correct language
660
+ fields: [
661
+ [
662
+ {
663
+ id: "firstName",
664
+ label: { en: "First Name", el: "Όνομα" },
665
+ value: "John", // The actual user input value
666
+ valueLabel: { en: "John", el: "John" } // Same label as the value for text inputs
667
+ },
668
+ {
669
+ id: "lastName",
670
+ label: { en: "Last Name", el: "Επίθετο" },
671
+ value: "Doe", // The actual user input value
672
+ valueLabel: { en: "Doe", el: "Doe" } // Same label as the value for text inputs
673
+ },
674
+ {
675
+ id: "gender",
676
+ label: { en: "Gender", el: "Φύλο" },
677
+ value: "m", // The actual value ("male")
678
+ valueLabel: { en: "Male", el: "Άντρας" } // The corresponding label for "male"
679
+ },
680
+ {
681
+ id: "languages",
682
+ label: { en: "Languages", el: "Γλώσσες" },
683
+ value: ["en", "el"], // The selected values ["en", "el"]
684
+ valueLabel: [
685
+ { en: "English", el: "Αγγλικά" }, // Labels corresponding to "en" and "el"
686
+ { en: "Greek", el: "Ελληνικά" }
687
+ ]
688
+ },
689
+ {
690
+ id: "birthDate",
691
+ label: { en: "Birth Date", el: "Ημερομηνία Γέννησης" },
692
+ value: "1990-01-13", // The actual value based on user input
693
+ valueLabel: "13/1/1990" // Date inputs label will be conveted to D/M/YYYY
694
+ }
695
+ ]
696
+ },
697
+ ...
685
698
  ]
686
699
 
687
700
 
688
701
 
689
- */
702
+ */