@gov-cy/govcy-express-services 1.0.0-alpha.18 → 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.
@@ -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.18",
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,13 +245,62 @@ export function govcyFileDeletePostHandler() {
220
245
 
221
246
  //if no validation errors
222
247
  if (req.body.deleteFile === "yes") {
223
- //TODO: Check if fileDeleteAPIEndpoint exists and call the API to delete the file from the storage
224
- // if it exists, check that the Env vars are set
225
- // construct url
226
- // get user
227
- // call the API
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
+ }
228
301
  // if succeeded all good
229
- dataLayer.storePageDataElement(req.session, siteId, pageUrl, elementName, "");
302
+ // dataLayer.storePageDataElement(req.session, siteId, pageUrl, elementName, "");
303
+ dataLayer.removeAllFilesFromSite(req.session, siteId, { fileId: elementData.fileId, sha256: elementData.sha256 });
230
304
  logger.info(`File deleted by user`, { siteId, pageUrl, elementName });
231
305
  }
232
306
  // construct the page url
@@ -239,6 +313,7 @@ export function govcyFileDeletePostHandler() {
239
313
  // redirect to the page (relative path)
240
314
  res.redirect(myUrl.pathname + myUrl.search);
241
315
  } catch (error) {
316
+ logger.error("Error in govcyFileDeletePostHandler middleware:", error.message);
242
317
  return next(error); // Pass error to govcyHttpErrorHandler
243
318
  }
244
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
+