data-primals-engine 1.4.0 → 1.4.2

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.
Files changed (40) hide show
  1. package/README.md +856 -797
  2. package/client/src/AssistantChat.jsx +48 -5
  3. package/client/src/AssistantChat.scss +0 -1
  4. package/client/src/ChartConfigModal.jsx +34 -9
  5. package/client/src/Dashboard.jsx +396 -349
  6. package/client/src/DashboardChart.jsx +91 -12
  7. package/client/src/DataTable.jsx +817 -807
  8. package/client/src/Field.jsx +1784 -1782
  9. package/client/src/PackGallery.jsx +391 -290
  10. package/client/src/PackGallery.scss +231 -210
  11. package/client/src/contexts/UIContext.jsx +5 -2
  12. package/client/src/translations.js +188 -0
  13. package/package.json +19 -8
  14. package/src/config.js +2 -2
  15. package/src/core.js +21 -3
  16. package/src/engine.js +2 -1
  17. package/src/events.js +4 -3
  18. package/src/gameObject.js +1 -1
  19. package/src/middlewares/middleware-mongodb.js +3 -1
  20. package/src/modules/assistant/assistant.js +131 -42
  21. package/src/modules/auth-google/index.js +51 -0
  22. package/src/modules/auth-microsoft/index.js +82 -0
  23. package/src/modules/auth-saml/index.js +90 -0
  24. package/src/modules/bucket.js +3 -2
  25. package/src/modules/data/data.backup.js +374 -0
  26. package/src/modules/data/data.history.js +11 -8
  27. package/src/modules/data/data.js +190 -4543
  28. package/src/modules/data/data.operations.js +2790 -0
  29. package/src/modules/data/data.relations.js +687 -0
  30. package/src/modules/data/data.routes.js +1785 -1646
  31. package/src/modules/data/data.scheduling.js +274 -0
  32. package/src/modules/data/data.validation.js +245 -0
  33. package/src/modules/data/index.js +29 -1
  34. package/src/modules/user.js +2 -1
  35. package/src/modules/workflow.js +3 -2
  36. package/src/providers.js +110 -1
  37. package/src/services/stripe.js +3 -2
  38. package/src/sso.js +194 -0
  39. package/test/data.integration.test.js +3 -0
  40. package/test/user.test.js +1 -1
@@ -0,0 +1,2790 @@
1
+ import {getObjectHash, getRandom, isPlainObject, randomDate, safeAssignObject} from "../../core.js";
2
+ import {
3
+ maxExportCount,
4
+ maxFileSize,
5
+ maxFilterDepth,
6
+ maxModelNameLength,
7
+ maxPasswordLength,
8
+ maxPostData,
9
+ maxRelationsPerData,
10
+ maxRequestData,
11
+ maxRichTextLength,
12
+ maxStringLength,
13
+ maxTotalDataPerUser,
14
+ megabytes,
15
+ optionsSanitizer,
16
+ searchRequestTimeout
17
+ } from "../../constants.js";
18
+ import {anonymizeText, getFieldValueHash, getUserId, isDemoUser, isLocalUser} from "../../data.js";
19
+ import sanitizeHtml from "sanitize-html";
20
+ import {importJobs, modelsCache, runCryptoWorkerTask, runImportExportWorker} from "./data.core.js";
21
+ import {
22
+ getCollection,
23
+ getCollectionForUser,
24
+ getUserCollectionName,
25
+ isObjectId,
26
+ modelsCollection,
27
+ packsCollection
28
+ } from "../mongodb.js";
29
+ import i18n from "../../i18n.js";
30
+ import {randomColor} from "randomcolor";
31
+ import {Config} from "../../config.js";
32
+ import {calculateTotalUserStorageUsage, hasPermission} from "../user.js";
33
+ import {BSON, ObjectId} from "mongodb";
34
+ import {runScheduledJobWithDbLock, triggerWorkflows} from "../workflow.js";
35
+ import schedule from "node-schedule";
36
+ import {sendSseToUser} from "./data.routes.js";
37
+ import {applyCronMask, handleScheduledJobs, runStatefulAlertJob} from "./data.scheduling.js";
38
+ import {Event} from "../../events.js";
39
+ import {getAllPacks} from "../../packs.js";
40
+ import {validateModelData, validateModelStructure} from "./data.validation.js";
41
+ import {addFile, removeFile} from "../file.js";
42
+ import NodeCache from "node-cache";
43
+ import fs from "node:fs";
44
+ import readXlsxFile from "read-excel-file/node";
45
+ import {checkServerCapacity} from "./data.js";
46
+ import {Logger} from "../../gameObject.js";
47
+ import {
48
+ changeValue,
49
+ checkHash,
50
+ convertDataTypes,
51
+ handleFilesIfNeeded,
52
+ processDocuments,
53
+ processFileArray
54
+ } from "./data.relations.js";
55
+ import cronstrue from 'cronstrue/i18n.js';
56
+
57
+ const delay = ms => new Promise(res => setTimeout(res, ms));
58
+ const IMPORT_CHUNK_SIZE = 100; // Nombre d'enregistrements à traiter par lot
59
+ const IMPORT_CHUNK_DELAY_MS = 1000; // Délai en millisecondes entre le traitement des lots
60
+
61
+ export const dataTypes = {
62
+ object: {
63
+ validate: (value, field) => {
64
+ return value === null || isPlainObject(value);
65
+ }
66
+ },
67
+ model: {
68
+ validate: (value, field) => {
69
+ return value === null || typeof value === 'string' && value.length <= maxModelNameLength;
70
+ }
71
+ },
72
+ cronSchedule: {
73
+ validate: (value, field) => {
74
+ if (value === null)
75
+ return true;
76
+ try {
77
+ cronstrue.toString(value, {throwExceptionOnParseError: true});
78
+ return true;
79
+ } catch (e) {
80
+ return false;
81
+ }
82
+ },
83
+ filter: async (value, field) => {
84
+ if (value === null)
85
+ return null;
86
+ if (field.cronMask && field.default) {
87
+ return applyCronMask(value, field.cronMask, field.default);
88
+ }
89
+ return value;
90
+ }
91
+ },
92
+ modelField: {
93
+ validate: (value, field) => {
94
+ return value === null || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
95
+ }
96
+ },
97
+ string: {
98
+ validate: (value, field) => {
99
+ const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
100
+ return value === null || typeof value === 'string' && (!ml || value.length <= ml)
101
+ },
102
+ anonymize: anonymizeText
103
+ },
104
+ code: {
105
+ validate: (value, field) => {
106
+ return value === null || (field.language === 'json' && typeof (value) === 'object') || (typeof value === 'string' && (field.maxlength === undefined || field.maxlength <= 0 || value.length <= field.maxlength));
107
+ },
108
+ filter: async (value, field) => {
109
+ if (field.language === 'json') {
110
+ if (typeof (value) === 'object')
111
+ return value;
112
+ else if (typeof (value) === 'string') {
113
+ try {
114
+ return JSON.parse(value);
115
+ } catch (e) {
116
+ return null;
117
+ }
118
+ } else {
119
+ return null;
120
+ }
121
+ }
122
+ return value;
123
+ },
124
+ anonymize: (str) => anonymizeText(typeof (str) === 'object' ? JSON.stringify(str) : str)
125
+ },
126
+ richtext: {
127
+ validate: (value, field) => {
128
+ const ml = Math.min(Math.max(field.maxlength, 0), maxRichTextLength);
129
+ return value === null || typeof value === 'string' && (!ml || value.length <= ml)
130
+ },
131
+ filter: async (value) => {
132
+ return sanitizeHtml(value, optionsSanitizer);
133
+ },
134
+ anonymize: anonymizeText
135
+ },
136
+ 'string_t': {
137
+ validate: (value, field) => {
138
+ if (value === null)
139
+ return true;
140
+ const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
141
+ // La valeur peut être une chaîne de caractères...
142
+ if (typeof value === 'string') {
143
+ return !ml || value.length <= ml;
144
+ }
145
+ // ... ou un objet contenant une clé de type chaîne de caractères.
146
+ if (typeof value === 'object' &&
147
+ (typeof value.key === 'string' || value.key === null)) {
148
+ return !ml || value.key.length <= ml;
149
+ }
150
+ return false; // Invalide si ce n'est aucun des deux.
151
+ },
152
+ filter: async (value, field) => {
153
+ // Si la valeur est un objet avec une clé, on ne garde que la clé.
154
+ if (typeof value === 'object' && value !== null &&
155
+ (typeof value.key === 'string' || value.key === null)) {
156
+ return value.key || '';
157
+ }
158
+ // Sinon, on garde la valeur telle quelle (qui devrait être une chaîne).
159
+ return value;
160
+ },
161
+ anonymize: anonymizeText
162
+ },
163
+ password: {
164
+ filter: async (value) => {
165
+ if (value)
166
+ return await runCryptoWorkerTask('hash', {data: value});
167
+ return null;
168
+ },
169
+ validate: (value, field) => {
170
+ const ml = Math.min(Math.max(field.maxlength, 0), maxPasswordLength);
171
+ return value === null || typeof value === 'string' && (!ml || value.length <= ml)
172
+ },
173
+ anonymize: anonymizeText
174
+ },
175
+ date: {
176
+ validate: (value, field) => {
177
+ if (value === null)
178
+ return true;
179
+ if (typeof (value) === 'string' && value.toLowerCase() === 'now')
180
+ return true;
181
+ if (typeof value !== 'string') return false;
182
+ const dt = new Date(value);
183
+
184
+ const dtMin = new Date(field.min || value);
185
+ const dtMax = new Date(field.max || value);
186
+ if (isNaN(dt)) {
187
+ return false;
188
+ }
189
+ return (dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
190
+ },
191
+ filter: async (value) => {
192
+ if (typeof (value) === 'string' && value.toLowerCase() === "now") {
193
+ return new Date().toISOString().split("T")[0];
194
+ }
195
+ if (value instanceof Date)
196
+ return value.toISOString().split("T")[0];
197
+ return value;
198
+ },
199
+ anonymize: (value, field) => {
200
+ const min = new Date();
201
+ const max = new Date();
202
+ min.setFullYear(min.getFullYear() - 1);
203
+ max.setFullYear(max.getFullYear() + 1);
204
+ return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
205
+ }
206
+ },
207
+ datetime: {
208
+ validate: (value, field) => {
209
+ if (typeof (value) === 'string' && value.toLowerCase() === 'now')
210
+ return true;
211
+ if (value instanceof Date || value === null)
212
+ return true;
213
+ if (typeof value !== 'string' || !value) return false;
214
+ const dt = new Date(value);
215
+ const dtMin = new Date(field.min || value);
216
+ const dtMax = new Date(field.max || value);
217
+ if (isNaN(dt)) {
218
+ return false;
219
+ }
220
+ return (dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
221
+ },
222
+ filter: async (value) => {
223
+ if (typeof (value) === 'string' && value.toLowerCase() === "now") {
224
+ return new Date().toISOString();
225
+ }
226
+ if (value instanceof Date)
227
+ return value.toISOString();
228
+ return value;
229
+ },
230
+ anonymize: (value, field) => {
231
+ const min = new Date();
232
+ const max = new Date();
233
+ min.setFullYear(min.getFullYear() - 1);
234
+ max.setFullYear(max.getFullYear() + 1);
235
+ return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
236
+ }
237
+ },
238
+ phone: {
239
+ prefixRegex: /^[+]?[(]?[0-9]{2,3}[)]?$/,
240
+ validate: (value) => {
241
+ if (value === null) return true;
242
+ if (typeof value !== 'string') return false;
243
+ if (!value) return true;
244
+ if (dataTypes.phone.prefixRegex.test(value)) return true;
245
+ const phoneRegex = /^[+]?[(]?[0-9]{2,3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/im;
246
+ return phoneRegex.test(value);
247
+ },
248
+ filter: async (value) => {
249
+ if (dataTypes.phone.prefixRegex.test(value)) return '';
250
+ return value;
251
+ },
252
+ anonymize: anonymizeText
253
+ },
254
+ url: {
255
+ validate: (value) => {
256
+ if (value === null) return true;
257
+ if (typeof value !== 'string') return false;
258
+ if (!value.trim()) return true;
259
+ const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
260
+ return expression.test(value);
261
+ },
262
+ anonymize: anonymizeText
263
+ },
264
+ number: {
265
+ validate: (value, field) => {
266
+ if (value === null) return true;
267
+ const min = typeof (field.min) === 'number' ? field.min : null;
268
+ const max = typeof (field.max) === 'number' ? field.max : null;
269
+ if (min !== null && max !== null && max < min)
270
+ return false;
271
+ return typeof value === 'number' && !isNaN(value) && (min === null || value >= min) && (max === null || value <= max);
272
+ },
273
+ anonymize: (value, field) => {
274
+ const min = typeof (field.min) === 'number' ? field.min : 0;
275
+ const max = typeof (field.max) === 'number' ? field.max : Math.MAX_SAFE_INTEGER;
276
+ return getRandom(min, max);
277
+ }
278
+ },
279
+ boolean: {
280
+ validate: (value) => value === null || typeof value === 'boolean',
281
+ anonymize: () => {
282
+ return !!getRandom(0, 1);
283
+ }
284
+ },
285
+ array: {
286
+ validate: (value, field) => {
287
+ if (value === null) return true;
288
+ if (!Array.isArray(value)) {
289
+ return false;
290
+ }
291
+ if (field.minItems && value.length < field.minItems) {
292
+ return false;
293
+ }
294
+ if (field.maxItems && value.length > field.maxItems) {
295
+ return false;
296
+ }
297
+ return value.every(item => {
298
+ if (!dataTypes[field.itemsType]) {
299
+ throw new Error(`Invalid itemsType: ${field.itemsType}`);
300
+ }
301
+ return dataTypes[field.itemsType].validate(item, {field, type: field.itemsType});
302
+ });
303
+ },
304
+ anonymize: () => []
305
+ },
306
+ enum: {
307
+ validate: (value) => value === null || typeof (value) === 'string',
308
+ anonymize: (value, field) => {
309
+ return field.items[Math.floor(Math.random() * field.items.length)];
310
+ }
311
+ },
312
+ // Types personnalisés
313
+ email: {
314
+ validate: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
315
+ anonymize: anonymizeText
316
+ },
317
+ relation: {
318
+ validate: (value, field) => {
319
+ if (field.multiple) {
320
+ return typeof (value) === 'object' || (Array.isArray(value) && value.length <= maxRelationsPerData && !value.some(v => {
321
+ return !isObjectId(v);
322
+ }));
323
+ }
324
+ return value === null || value === undefined || isObjectId(value) || typeof (value) === 'object';
325
+ },
326
+ anonymize: () => null
327
+ },
328
+ file: {
329
+ validate: (value, field) => {
330
+ // If no file is selected, it's considered valid (optional field)
331
+ if (value === null || value === undefined) {
332
+ return true;
333
+ }
334
+
335
+ // Check if the value is a string (filename or GUID)
336
+ if (typeof value === 'string') {
337
+ return true;
338
+ }
339
+
340
+ // Check if the value is a File object
341
+ if (typeof (value) === 'object') {
342
+ if (field.mimeTypes && !field.mimeTypes.includes(value.type)) {
343
+ throw new Error(i18n.t('api.validate.invalidMimeType', {
344
+ type: value.type,
345
+ authorized: field.mimeTypes.join(', ')
346
+ }));
347
+ }
348
+
349
+ // Check if the file size is within the limit
350
+ if (value.size > (field.maxSize || maxFileSize)) {
351
+ return false;
352
+ }
353
+
354
+ return true;
355
+ }
356
+
357
+ return false; // Invalid type
358
+ },
359
+ filter: async (value, field, reqFile) => {
360
+ if (typeof value !== 'object') {
361
+ return null;
362
+ }
363
+
364
+ return value;
365
+ },
366
+ anonymize: () => null
367
+ },
368
+ color: {
369
+ validate: (value) => {
370
+ // Vérification si la valeur est une chaîne de caractères et correspond à un format de couleur hexadécimal valide.
371
+ return value === null || typeof value === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
372
+ },
373
+ filter: async (value) => {
374
+ // Nettoyage ou transformation de la valeur si nécessaire (par exemple, mise en majuscule des caractères hexadécimaux).
375
+ return value ? value.toUpperCase() : null; // Retourne null si la valeur est null ou undefined
376
+ },
377
+ anonymize: () => {
378
+ return randomColor({
379
+ format: 'hex'
380
+ });
381
+ }
382
+ },
383
+ calculated: {
384
+ validate: (value) => {
385
+ return value !== undefined && value !== null && typeof value !== 'object' && !Array.isArray(value);
386
+ },
387
+ filter: async (value) => {
388
+ return value;
389
+ }
390
+ },
391
+ richtext_t: {
392
+ validate: (value, field) => {
393
+ // La valeur doit être un objet (ou null/undefined)
394
+ if (value === null || typeof value === 'undefined') return true;
395
+ if (typeof value !== 'object' || Array.isArray(value)) return false;
396
+
397
+ // Chaque valeur dans l'objet doit être une chaîne de caractères (le HTML)
398
+ return Object.values(value).every(html => typeof html === 'string');
399
+ },
400
+ filter: async (value) => {
401
+ if (!value) return null;
402
+ const sanitizedObject = {};
403
+ for (const lang in value) {
404
+ if (Object.prototype.hasOwnProperty.call(value, lang)) {
405
+ // On réutilise le même sanitizer que pour le richtext simple
406
+ safeAssignObject(sanitizedObject, lang, sanitizeHtml(value[lang], optionsSanitizer));
407
+ }
408
+ }
409
+ return sanitizedObject;
410
+ },
411
+ anonymize: () => ({}) // Anonymisation en objet vide
412
+ }
413
+ };
414
+
415
+
416
+
417
+ let engine, logger;
418
+ export function onInit(defaultEngine) {
419
+ engine = defaultEngine;
420
+ logger = engine.getComponent(Logger);
421
+ }
422
+
423
+ export const editModel = async (user, id, data) => {
424
+
425
+ if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_EDIT_MODEL"], user)) {
426
+ return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
427
+ }
428
+
429
+ const dataModel = data;
430
+ try {
431
+ const collection = await getCollectionForUser(user);
432
+ await validateModelStructure(dataModel);
433
+
434
+ const el = await modelsCollection.findOne({
435
+ $and: [
436
+ {_user: {$exists: true}},
437
+ {_id: new ObjectId(id)},
438
+ {
439
+ $and: [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}]
440
+ }
441
+ ]
442
+ });
443
+
444
+ if (!el) {
445
+ return ({success: false, statusCode: 404, error: i18n.t("api.model.notFound", {model: dataModel.name})});
446
+ }
447
+
448
+ // renommage du modèle
449
+ if (typeof (data.name) === 'string' && el.name !== data.name && data.name) {
450
+ await collection.updateMany({_model: el.name}, {$set: {_model: data.name}});
451
+ await modelsCollection.updateMany({
452
+ 'fields': {
453
+ '$elemMatch': {relation: el.name}
454
+ }
455
+ }, {
456
+ $set: {
457
+ 'fields.$.relation': data.name
458
+ }
459
+ })
460
+ }
461
+
462
+ const coll = await getCollectionForUser(user);
463
+ // Update indexes
464
+ // Update indexes
465
+ if (await engine.userProvider.hasFeature(user, 'indexes')) {
466
+ let indexes = [];
467
+ try {
468
+ // On essaie de récupérer les index existants
469
+ indexes = await coll.indexes();
470
+ } catch (e) {
471
+ // Si la collection n'existe pas, c'est normal.
472
+ // createIndex la créera. Il n'y a juste pas d'index à supprimer.
473
+ if (e.codeName !== 'NamespaceNotFound') {
474
+ throw e; // On relance les autres erreurs
475
+ }
476
+ }
477
+
478
+ // Le reste de votre logique de gestion d'index peut maintenant s'exécuter en toute sécurité
479
+ for (const field of data.fields) {
480
+ const elField = el.fields.find(f => f.name === field.name);
481
+ if (!elField) continue;
482
+
483
+ const index = indexes.find(i => i.key[field.name] === 1 &&
484
+ i.partialFilterExpression?._model === el.name &&
485
+ i.partialFilterExpression?._user === user.username);
486
+
487
+ if (elField.index !== field.index && !field.index) {
488
+ if (index) {
489
+ await coll.dropIndex(index.name);
490
+ }
491
+ } else if (elField.index !== field.index && field.index) {
492
+ if (!index) {
493
+ await coll.createIndex({[field.name]: 1}, {
494
+ partialFilterExpression: {
495
+ _model: data.name,
496
+ _user: user.username
497
+ }
498
+ });
499
+ }
500
+ }
501
+ }
502
+ }
503
+ // suppression des données à la suppression des champs
504
+ const unset = {};
505
+ el.fields.filter(f => !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
506
+ unset[f] = 1;
507
+ });
508
+ await collection.updateMany({_model: el.name}, {$unset: unset});
509
+
510
+ // sauvegarde du modele
511
+ const set = {...data};
512
+ delete set['_id'];
513
+
514
+ const oid = new ObjectId(id);
515
+ await modelsCollection.updateOne({_id: oid}, {$set: set});
516
+
517
+ modelsCache.del(user.username + '@@' + el.name);
518
+
519
+ const model = await modelsCollection.findOne({_id: oid});
520
+ triggerWorkflows(model, user, 'ModelEdited').catch(workflowError => {
521
+ logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
522
+ });
523
+
524
+ const newModel = await modelsCollection.findOne({_id: oid});
525
+ const res = ({success: true, data: newModel});
526
+ const plugin = await Event.Trigger("OnModelEdited", "event", "system", engine, newModel);
527
+ await Event.Trigger("OnModelEdited", "event", "user", plugin?.data || newModel);
528
+ return plugin || res
529
+ } catch (e) {
530
+ logger.error(e);
531
+ return ({success: false, error: e.message, statusCode: 500});
532
+ }
533
+ };
534
+ export const createModel = async (data) => {
535
+ return await getCollection('models').insertOne(data);
536
+ }
537
+ export const deleteModels = async (filter) => {
538
+ return await getCollection('models').deleteMany(filter ? filter : {_user: {$exists: false}});
539
+ }
540
+ export const getModel = async (modelName, user) => {
541
+ const modelInCache = modelsCache.get((user?.username || '') + "@@" + modelName);
542
+ if (modelInCache)
543
+ return modelInCache;
544
+ const model = await getCollection('models').findOne({
545
+ name: modelName,
546
+ $and: user ? [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}] : [{_user: {$exists: false}}]
547
+ });
548
+ if (!model) {
549
+ throw new Error(i18n.t('api.model.notFound', {model: modelName}));
550
+ }
551
+ modelsCache.set((user?.username || '') + "@@" + modelName, model);
552
+ return model;
553
+ }
554
+ export const getModels = async () => {
555
+ return await getCollection('models')?.find({'$or': [{_user: {$exists: false}}]}).toArray() || [];
556
+ }
557
+ export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true) => {
558
+
559
+ // --- Vérification des permissions (inchangée) ---
560
+ if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && (
561
+ !await hasPermission(["API_ADMIN", "API_ADD_DATA", "API_ADD_DATA_" + modelName], user) ||
562
+ await hasPermission(["API_ADD_DATA_NOT_" + modelName], user))) {
563
+ // Renvoyer une structure d'erreur cohérente
564
+ return {success: false, error: i18n.t('api.permission.addData'), statusCode: 403};
565
+ }
566
+
567
+ const collection = await getCollectionForUser(user);
568
+ let insertedIds = []; // Pour stocker les IDs retourn par pushDataUnsecure
569
+
570
+ try {
571
+ // --- Insertion via pushDataUnsecure (inchangée) ---
572
+ insertedIds = await pushDataUnsecure(data, modelName, user, files);
573
+
574
+ // Check if pushDataUnsecure actually returned IDs
575
+ if (!insertedIds || insertedIds.length === 0) {
576
+ logger.warn(`[insertData] pushDataUnsecure did not return inserted IDs for model ${modelName}.`);
577
+ // Retourner échec si l'insertion n'a pas retourné d'IDs
578
+ return {
579
+ success: false,
580
+ unmodified: true,
581
+ error: "Insertion failed, no IDs returned by core function.",
582
+ statusCode: 500
583
+ };
584
+ }
585
+
586
+ // Convertir les IDs en ObjectId pour la recherche
587
+ const objectIds = insertedIds.map(id => new ObjectId(id));
588
+ const insertedDocs = await collection.find({_id: {$in: objectIds}}).toArray();
589
+
590
+ if (!insertedDocs || insertedDocs.length === 0) {
591
+ logger.warn(`[insertData] Could not fetch inserted documents after pushDataUnsecure (IDs: ${insertedIds.join(', ')}).`);
592
+ // Continuer même si on ne peut pas fetch, l'insertion a réussi.
593
+ } else {
594
+ // --- Logique de post-insertion (Workflows et Planification) ---
595
+ const postInsertionPromises = insertedDocs.map(async (doc) => {
596
+ // 1. Déclencher les workflows 'DataAdded' (si activé)
597
+ if (triggerWorkflow) {
598
+ const prom = triggerWorkflows(doc, user, 'DataAdded').then(e => {
599
+ logger.debug(`[insertData] Triggered DataAdded workflow for ${doc._model} ID ${doc._id}.`);
600
+ }).catch(e => {
601
+ logger.error(`[insertData] Error triggering DataAdded workflow for ${doc._model} ID ${doc._id}:`, e);
602
+ });
603
+ if (waitForWorkflow) {
604
+ await prom;
605
+ }
606
+ }
607
+
608
+ if (doc._model === 'workflowTrigger' && doc.isActive === true && doc.cronExpression) {
609
+ const jobId = `workflowTrigger_${doc._id}`;
610
+ logger.info(`[insertData] Scheduling new active workflowTrigger ${doc._id} (${doc.name || 'No Name'}) with cron: "${doc.cronExpression}"`);
611
+
612
+ if (schedule.scheduledJobs[jobId]) {
613
+ logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
614
+ schedule.scheduledJobs[jobId].cancel();
615
+ }
616
+
617
+ try {
618
+ schedule.scheduleJob(jobId, doc.cronExpression, async () => {
619
+ logger.info(`[Scheduled Job] Cron triggered for job ${jobId}. Attempting to run with lock...`);
620
+ await runScheduledJobWithDbLock(
621
+ jobId,
622
+ async () => {
623
+ // --- NOUVELLE LOGIQUE D'ALERTE ---
624
+ logger.info(`[Scheduled Job] Executing alert logic for workflow ${doc.name} (ID: ${doc._id}) for user ${doc._user}`);
625
+
626
+ const alertPayload = {
627
+ type: 'cron_alert',
628
+ triggerId: doc._id.toString(),
629
+ triggerName: doc.name,
630
+ timestamp: new Date().toISOString(),
631
+ message: `L'alerte planifiée '${doc.name || 'Sans nom'}' a été déclenchée.`
632
+ };
633
+
634
+ // Envoyer l'alerte à l'utilisateur spécifique via SSE
635
+ const sent = await sendSseToUser(doc._user, alertPayload);
636
+
637
+ if (sent) {
638
+ logger.info(`[Scheduled Job] Successfully sent SSE alert for job ${jobId} to user ${doc._user}.`);
639
+ } else {
640
+ logger.warn(`[Scheduled Job] Could not send SSE alert for job ${jobId}. User ${doc._user} is not connected via SSE.`);
641
+ }
642
+ // --- FIN DE LA LOGIQUE D'ALERTE ---
643
+ },
644
+ doc.lockDurationMinutes || 5
645
+ );
646
+ });
647
+ logger.info(`[insertData] Successfully scheduled job ${jobId}.`);
648
+ } catch (scheduleError) {
649
+ logger.error(`[insertData] Failed to schedule job ${jobId} for workflowTrigger ${doc._id}. Error: ${scheduleError.message}`);
650
+ }
651
+ } else if (doc._model === 'alert' && doc.isActive === true && doc.frequency) {
652
+ const jobId = `alert_${doc._id}`;
653
+ logger.info(`[insertData] Scheduling new active alert ${doc._id} (${doc.name}) with frequency: "${doc.frequency}"`);
654
+
655
+ if (schedule.scheduledJobs[jobId]) {
656
+ logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
657
+ schedule.scheduledJobs[jobId].cancel();
658
+ }
659
+
660
+ try {
661
+ // --- MODIFICATION ICI ---
662
+ schedule.scheduleJob(jobId, doc.frequency, () => runStatefulAlertJob(doc._id));
663
+ // --- FIN MODIFICATION ---
664
+ logger.info(`[insertData] Successfully scheduled alert job ${jobId}.`);
665
+ } catch (scheduleError) {
666
+ logger.error(`[insertData] Failed to schedule alert job ${jobId}. Error: ${scheduleError.message}`);
667
+ }
668
+ }
669
+
670
+ });
671
+
672
+ // Attendre que toutes les opérations post-insertion (workflows, planification) soient tentées
673
+ await Promise.allSettled(postInsertionPromises);
674
+ }
675
+
676
+ // System specific event
677
+ const eventPayload = {modelName, insertedIds, user};
678
+ await Event.Trigger("OnDataAdded", "event", "system", engine, eventPayload);
679
+
680
+ // User specific event
681
+ const userPayload = {...eventPayload};
682
+ delete userPayload['user'];
683
+ await Event.Trigger("OnDataAdded", "event", "user", userPayload);
684
+
685
+ // Return valid result
686
+ return {success: true, insertedIds: insertedIds.map(id => id.toString())}; // Convertir les IDs en string pour la réponse
687
+
688
+ } catch (error) { // Attrape les erreurs de permission ou de pushDataUnsecure
689
+ logger.error(`[insertData] Main error during insertion process for model ${modelName}: ${error.message}`, error.stack);
690
+ // Renvoyer une structure d'erreur cohérente
691
+ return {
692
+ success: false,
693
+ unmodified: error.unmodified,
694
+ error: error.message || "Insertion failed due to an unexpected error.",
695
+ statusCode: error.statusCode || 500
696
+ };
697
+ }
698
+ };
699
+ /**
700
+ * Fonction principale pour l'insertion de données avec gestion des relations
701
+ * @param {Array<object>|object} data - Données à insérer
702
+ * @param {string} modelName - Nom du modèle cible
703
+ * @param {object} me - Utilisateur courant
704
+ * @param {object} [files={}] - Fichiers associés (optionnel)
705
+ * @returns {Promise<Array<string>>} IDs des documents insérés/trouvés
706
+ */
707
+ export const pushDataUnsecure = async (data, modelName, me, files = {}) => {
708
+ try {
709
+ // 1. Initialisation et validation
710
+ const {datas, model, collection} = await initializeAndValidate(data, modelName, me);
711
+ if (datas.length === 0) {
712
+ return [];
713
+ }
714
+
715
+ // 2. Vérification des limites (en parallèle avec les contraintes)
716
+ const [_, violations] = await Promise.all([
717
+ checkLimits(datas, model, collection, me),
718
+ checkCompositeUniqueConstraints(datas, model, collection, me)
719
+ ]);
720
+
721
+ if (violations.length > 0) {
722
+ throw new Error(`Violation of unique constraints :\n${violations.join('\n')}`);
723
+ }
724
+
725
+ // 3. Traitement des documents
726
+ const {allInsertedIds, idMap} = await processDocuments(datas, model, collection, me);
727
+
728
+ // 4. Gestion des fichiers (optionnel)
729
+ await handleFilesIfNeeded(allInsertedIds, files, model, collection);
730
+
731
+ return allInsertedIds;
732
+ } catch (e) {
733
+ throw e;
734
+ }
735
+ };
736
+
737
+ async function checkCompositeUniqueConstraints(datas, model, collection, user) {
738
+ if (!model.constraints?.length) return [];
739
+
740
+ const uniqueConstraints = model.constraints.filter(c => c.type === 'unique' && c.keys?.length);
741
+ if (!uniqueConstraints.length) return [];
742
+
743
+ // Préparation des vérifications
744
+ const violations = [];
745
+ const userId = user._user || user.username;
746
+
747
+ // Paralléliser par contrainte
748
+ await Promise.all(uniqueConstraints.map(async (constraint) => {
749
+ // Vérifier les champs de la contrainte
750
+ const invalidFields = constraint.keys.filter(key =>
751
+ !model.fields.some(f => f.name === key)
752
+ );
753
+
754
+ if (invalidFields.length) {
755
+ violations.push(`Fields used in constraint [${invalidFields.join(', ')}] '${constraint.name}' are inexistant.`);
756
+ return;
757
+ }
758
+
759
+ // Batch processing des documents (500 max)
760
+ const batchSize = 50;
761
+ for (let i = 0; i < datas.length; i += batchSize) {
762
+ const batch = datas.slice(i, i + batchSize);
763
+
764
+ // Créer toutes les requêtes pour ce batch
765
+ const queries = batch.flatMap(doc => {
766
+ const compositeKey = {};
767
+ let hasNull = false;
768
+
769
+ for (const key of constraint.keys) {
770
+ if (doc[key] == null) {
771
+ hasNull = true;
772
+ break;
773
+ }
774
+ compositeKey[key] = doc[key];
775
+ }
776
+
777
+ return hasNull ? [] : [{
778
+ collection,
779
+ query: {
780
+ ...compositeKey,
781
+ _model: model.name,
782
+ _user: userId
783
+ }
784
+ }];
785
+ });
786
+
787
+ // Exécution en parallèle avec $or pour réduire les appels
788
+ if (queries.length) {
789
+ const matchingDocs = await collection.find({
790
+ $or: queries.map(q => q.query)
791
+ }).toArray();
792
+
793
+ if (matchingDocs.length) {
794
+ // Créer un Set des clés existantes pour recherche rapide
795
+ const existingKeys = new Set(
796
+ matchingDocs.map(doc =>
797
+ constraint.keys.map(k => doc[k]).join('|')
798
+ )
799
+ );
800
+
801
+ // Vérifier chaque document du batch
802
+ batch.forEach(doc => {
803
+ const keyValues = constraint.keys.map(k => doc[k]);
804
+ if (keyValues.every(v => v != null)) {
805
+ const compositeKey = keyValues.join('|');
806
+ if (existingKeys.has(compositeKey)) {
807
+ violations.push(
808
+ `[${constraint.name}] Existing data : ${constraint.keys.map((k, i) => `${k}=${keyValues[i]}`).join(', ')}`
809
+ );
810
+ }
811
+ }
812
+ });
813
+ }
814
+ }
815
+ }
816
+ }));
817
+
818
+ return violations;
819
+ }
820
+
821
+ /**
822
+ * Initialise et valide les paramètres d'entrée
823
+ */
824
+ async function initializeAndValidate(data, modelName, me) {
825
+ const datas = normalizeInputData(data);
826
+ if (datas.length === 0) return {datas: [], model: null, collection: null};
827
+
828
+ const model = await getModel(modelName, me);
829
+ const collection = await getCollectionForUser(me);
830
+ await validateModelStructure(model);
831
+
832
+ return {datas, model, collection};
833
+ }
834
+
835
+ /**
836
+ * Normalise les données d'entrée (tableau ou objet unique)
837
+ */
838
+ function normalizeInputData(data) {
839
+ if (Array.isArray(data)) return data;
840
+ if (isPlainObject(data)) return [data];
841
+ return [];
842
+ }
843
+
844
+ /**
845
+ * Vérifie toutes les limites (stockage, capacité, etc.)
846
+ */
847
+ async function checkLimits(datas, model, collection, me) {
848
+ const incomingDataSize = calculateDataSize(datas);
849
+ const userStorageLimit = await engine.userProvider.getUserStorageLimit(me);
850
+
851
+ // Vérification des limites utilisateur
852
+ const currentStorageUsage = await calculateTotalUserStorageUsage(me);
853
+ if (currentStorageUsage + incomingDataSize > userStorageLimit) {
854
+ throw new Error(i18n.t("api.data.storageLimitExceeded", {
855
+ limit: Math.round(userStorageLimit / megabytes)
856
+ }));
857
+ }
858
+
859
+ // Vérification capacité serveur
860
+ const serverCapacity = await checkServerCapacity(incomingDataSize);
861
+ if (!serverCapacity.isSufficient) {
862
+ throw new Error(i18n.t("api.data.serverStorageFull"));
863
+ }
864
+
865
+ // Vérification nombre max de documents
866
+ const count = await collection.countDocuments({_user: me._user || me.username});
867
+ if (count + datas.length > maxTotalDataPerUser) {
868
+ throw new Error(i18n.t("api.data.tooManyData"));
869
+ }
870
+
871
+ if (datas.length > maxPostData) {
872
+ throw new Error(i18n.t('api.data.tooManyData'));
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Calcule la taille des données (en octets)
878
+ */
879
+ function calculateDataSize(datas) {
880
+ try {
881
+ return BSON.calculateObjectSize(datas);
882
+ } catch (e) {
883
+ logger.warn("[Storage] Fallback to JSON.stringify for size estimation.");
884
+ return JSON.stringify(datas).length;
885
+ }
886
+ }
887
+
888
+ export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
889
+ return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
890
+ };
891
+ export const editData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
892
+ return await internalEditOrPatchData(modelName, filter, data, files, user, false, triggerWorkflow, waitForWorkflow);
893
+ };
894
+ const internalEditOrPatchData = async (modelName, filter, data, files, user, isPatch, triggerWorkflow = true, waitForWorkflow = false) => {
895
+ try {
896
+ // 1. Vérification des permissions
897
+ if (user.username !== 'demo' && isLocalUser(user) && (
898
+ !await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_" + modelName], user) ||
899
+ await hasPermission(["API_EDIT_DATA_NOT_" + modelName], user))) {
900
+ throw new Error(i18n.t("api.permission.editData"));
901
+ }
902
+
903
+ const collection = await getCollectionForUser(user);
904
+ const model = await modelsCollection.findOne({name: modelName, _user: user.username});
905
+ if (!model) {
906
+ throw new Error(i18n.t("api.model.notFound", {model: modelName}));
907
+ }
908
+
909
+ // 2. Récupération des documents existants et de leur hash original
910
+ const existingDocs = (await searchData({model: modelName, filter}, user))?.data;
911
+ if (!existingDocs || existingDocs.length === 0) {
912
+ return {success: false, error: i18n.t("api.data.notFound")};
913
+ }
914
+ const ids = existingDocs.map(d => new ObjectId(d._id));
915
+ const originalHash = existingDocs[0]._hash; // Sauvegarde du hash avant modification
916
+
917
+ // 3. Préparation des données de mise à jour (inchangé)
918
+ const updateData = {...data};
919
+ delete updateData._model;
920
+ delete updateData._user;
921
+
922
+ // Traitement des fichiers (inchangé)
923
+ const fileFields = model.fields.filter(f => f.type === 'file' || (f.type === 'array' && f.itemsType === 'file'));
924
+ for (const field of fileFields) {
925
+ if (files?.[field.name + '[0]']) {
926
+ if (field.type === 'file') {
927
+ updateData[field.name] = await addFile(files[field.name + '[0]'][0], user);
928
+ } else if (field.type === 'array' && field.itemsType === 'file') {
929
+ const currentFiles = existingDocs[0]?.[field.name] || [];
930
+ const newFiles = await processFileArray(files[field.name + '[0]'], currentFiles, user);
931
+ updateData[field.name] = newFiles;
932
+ }
933
+ }
934
+ }
935
+
936
+ // 4. Validation adaptée pour patch ou edit (inchangé)
937
+ if (!isPatch) {
938
+ const dataToValidate = {...existingDocs[0], ...updateData};
939
+ await validateModelData(dataToValidate, model, false);
940
+ } else {
941
+ await validateModelData(updateData, model, true);
942
+ }
943
+
944
+ // 5. Vérification des champs uniques (inchangé)
945
+ const uniqueFields = model.fields.filter(f => f.unique);
946
+ for (const field of uniqueFields) {
947
+ if (updateData[field.name] !== undefined) {
948
+ const existing = await collection.findOne({
949
+ _user: user._user || user.username,
950
+ _model: modelName,
951
+ [field.name]: updateData[field.name],
952
+ _id: {$nin: ids}
953
+ });
954
+ if (existing) {
955
+ throw new Error(i18n.t("api.data.duplicateValue", {
956
+ field: field.name,
957
+ value: updateData[field.name]
958
+ }));
959
+ }
960
+ }
961
+ }
962
+
963
+ // 6. Traitement des relations (inchangé)
964
+ const relationFields = model.fields.filter(f => f.type === 'relation');
965
+ for (const field of relationFields) {
966
+ if (updateData[field.name] !== undefined) {
967
+ const relationValue = updateData[field.name];
968
+ // Only process relations if the value is an object or an array containing at least one object.
969
+ // An array of strings (ObjectIDs) should be passed through as-is for the update.
970
+ let shouldProcessRelation = false;
971
+ if (Array.isArray(relationValue)) {
972
+ // If any item in the array is a plain object, we need to process the whole array
973
+ // to handle potential nested creations or lookups.
974
+ if (relationValue.some(item => isPlainObject(item))) {
975
+ shouldProcessRelation = true;
976
+ }
977
+ } else if (isPlainObject(relationValue)) {
978
+ shouldProcessRelation = true;
979
+ }
980
+ if (shouldProcessRelation) {
981
+ const insertedIds = await pushDataUnsecure(relationValue, field.relation, user);
982
+ updateData[field.name] = field.multiple ? insertedIds || [] : (insertedIds?.[0] || null);
983
+ }
984
+ }
985
+ }
986
+
987
+ // 7. Application des filtres de champ (ex: hashage de mot de passe) (inchangé)
988
+ for (const field of model.fields) {
989
+ if (updateData[field.name] !== undefined && dataTypes[field.type]?.filter) {
990
+ updateData[field.name] = await dataTypes[field.type].filter(
991
+ field.type === 'file' ? null : updateData[field.name],
992
+ field
993
+ );
994
+ }
995
+ }
996
+
997
+ for (const field of model.fields) {
998
+ if (field.type === 'relation' && field.relationFilter && updateData[field.name]) {
999
+
1000
+ const relatedIds = Array.isArray(updateData[field.name])
1001
+ ? updateData[field.name]
1002
+ : [updateData[field.name]];
1003
+
1004
+ // Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
1005
+ const validationQuery = {
1006
+ $and: [
1007
+ {$in: ['$_id', relatedIds.map(id => ({$toObjectId: id}))]},
1008
+ field.relationFilter
1009
+ ]
1010
+ };
1011
+
1012
+ const relatedDocs = await searchData({
1013
+ filter: validationQuery,
1014
+ model: field.relation,
1015
+ limit: relatedIds.length
1016
+ }, user);
1017
+
1018
+ if ((relatedDocs?.count || 0) !== relatedIds.length) {
1019
+ const invalidIds = relatedIds.filter(id =>
1020
+ !relatedDocs.data.some(doc => doc._id.toString() === id.toString())
1021
+ );
1022
+ throw new Error(
1023
+ i18n.t(
1024
+ 'api.data.relationFilterFailed',
1025
+ 'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
1026
+ {field: field.name, values: invalidIds.join(', ')}
1027
+ )
1028
+ );
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ // 8. Calcul du nouveau hash et préparation des données finales
1034
+ const finalStateForHash = {...existingDocs[0], ...updateData};
1035
+ const newHash = getFieldValueHash(model, finalStateForHash);
1036
+
1037
+ const finalDataForSet = {
1038
+ ...updateData,
1039
+ _hash: newHash
1040
+ };
1041
+
1042
+ // 9. *** CORRECTION LOGIQUE ***
1043
+ // On ne vérifie l'unicité que si le hash a réellement changé.
1044
+ if (newHash !== originalHash) {
1045
+ const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
1046
+ if (hashCheck) {
1047
+ // Le nouvel état du document créerait un doublon.
1048
+ throw new Error(i18n.t("api.data.notUniqueData"));
1049
+ }
1050
+ }
1051
+
1052
+ // 10. Exécution de la mise à jour (inchangé)
1053
+ const bulkOps = [{updateMany: {filter: {_id: {$in: ids}}, update: {$set: finalDataForSet}}}];
1054
+ const bulkResult = await collection.bulkWrite(bulkOps);
1055
+ const modifiedCount = bulkResult.modifiedCount || 0;
1056
+
1057
+ // Déclencher l'événement OnDataEdited avec les états avant/après
1058
+ if (modifiedCount > 0) {
1059
+ const updatedDocs = await collection.find({_id: {$in: ids}}).toArray();
1060
+ await Event.Trigger("OnDataEdited", "event", "system", engine, {
1061
+ modelName,
1062
+ user,
1063
+ before: existingDocs, // Documents avant la modification
1064
+ after: updatedDocs // Documents après la modification
1065
+ });
1066
+ }
1067
+
1068
+ // 11. Tâches post-mise à jour (schedules, workflows) (inchangé)
1069
+ if (["workflowTrigger", "alert"].includes(modelName)) {
1070
+ await handleScheduledJobs(modelName, existingDocs, collection, finalDataForSet);
1071
+ }
1072
+
1073
+ if (triggerWorkflow && modifiedCount > 0) {
1074
+ const updatedDoc = await collection.findOne({_id: ids[0]});
1075
+ if (updatedDoc) {
1076
+ const proms = triggerWorkflows(updatedDoc, user, 'DataEdited')
1077
+ .catch(err => logger.error("[editData] Workflow trigger error:", err));
1078
+ if (waitForWorkflow) {
1079
+ await proms;
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ return {
1085
+ success: true,
1086
+ modifiedCount
1087
+ };
1088
+
1089
+ } catch (error) {
1090
+ logger.error("Erreur lors de la mise à jour de la ressource :", error);
1091
+ return {success: false, error: error.message};
1092
+ }
1093
+ };
1094
+ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow, waitForWorkflow = false) => {
1095
+
1096
+ try {
1097
+ const collection = await getCollectionForUser(user);
1098
+
1099
+ // --- Début de la logique de suppression ---
1100
+
1101
+ // 1. Construire le filtre de base pour trouver les documents à supprimer
1102
+ let findFilter = [];
1103
+ if (user)
1104
+ findFilter.push({
1105
+ '$eq': ["$_user", user.username]
1106
+ });
1107
+
1108
+ // Ajouter le filtre par IDs si fourni
1109
+ if (Array.isArray(filter) && filter.length > 0) {
1110
+ findFilter.push({"$in": ["$_id", filter.map(m => new ObjectId(m))]});
1111
+ }
1112
+
1113
+ // Ajouter le filtre par nom de modèle si fourni (utile si 'filter' est utilisé seul)
1114
+ if (modelName)
1115
+ findFilter.push({
1116
+ '$eq': ["$_model", modelName]
1117
+ });
1118
+
1119
+ // Ajouter le filtre supplémentaire si fourni
1120
+ if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
1121
+ // Fusionner prudemment le filtre supplémentaire
1122
+ // Attention: Si 'filter' contient des clés comme _id ou _user,
1123
+ // cela pourrait entrer en conflit. Une fusion plus robuste pourrait re nécessaire.
1124
+ findFilter.push(filter);
1125
+ } else {
1126
+
1127
+ }
1128
+
1129
+ // 2. Récupérer les documents à supprimer pour vérifier leur type et annuler les schedules
1130
+ const documentsToDelete = await collection.aggregate([{$match: {$expr: {"$and": findFilter}}}]).toArray();
1131
+
1132
+ if (documentsToDelete.length === 0) {
1133
+ logger.info(`[deleteData] No documents found matching the criteria for user ${user?.username}.`);
1134
+ return ({success: true, deletedCount: 0, message: "No documents found to delete."});
1135
+ }
1136
+
1137
+ const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
1138
+
1139
+ for (const docToDelete of documentsToDelete) {
1140
+ const deletePromises = [];
1141
+ const model = await getModel(docToDelete._model, user);
1142
+ for (const f of model.fields) {
1143
+ const fieldValue = docToDelete[f.name]; // Valeur du champ actuel depuis le document
1144
+ if (f.type === 'file') {
1145
+ if (typeof fieldValue === 'string' && fieldValue) { // fieldValue est un GUID
1146
+ deletePromises.push(removeFile(fieldValue, user));
1147
+ }
1148
+ } else if (f.type === 'array' && f.itemsType === 'file') {
1149
+ if (Array.isArray(fieldValue)) {
1150
+ for (const guidInArray of fieldValue) {
1151
+ if (typeof guidInArray === 'string' && guidInArray) {
1152
+ deletePromises.push(removeFile(guidInArray, user));
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ if (deletePromises.length > 0) {
1159
+ await Promise.allSettled(deletePromises);
1160
+ }
1161
+
1162
+ // Vérification des permissions (pour chaque document trouvé)
1163
+ if (user?.username !== 'demo' && isLocalUser(user) && (
1164
+ !await hasPermission(["API_ADMIN", "API_DELETE_DATA", "API_DELETE_DATA_" + docToDelete._model], user) ||
1165
+ await hasPermission(["API_DELETE_DATA_NOT_" + docToDelete._model], user))) {
1166
+ // Si l'utilisateur n'a pas la permission pour CE document spécifique, on l'ignore
1167
+ logger.warn(`[deleteData] User ${user.username} lacks permission to delete document ${docToDelete._id} of model ${docToDelete._model}. Skipping.`);
1168
+ continue; // Passe au document suivant
1169
+ }
1170
+
1171
+ // *** Ajout de l'annulation du schedule pour workflowTrigger ***
1172
+ if (docToDelete._model === 'workflowTrigger') {
1173
+ const jobId = `workflowTrigger_${docToDelete._id}`;
1174
+ const scheduledJob = schedule.scheduledJobs[jobId];
1175
+ if (scheduledJob) {
1176
+ scheduledJob.cancel();
1177
+ logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted workflowTrigger ${docToDelete._id}.`);
1178
+ } else {
1179
+ // Ce n'est pas forcément une erreur si le trigger n'avait pas de cronExpression
1180
+ logger.debug(`[deleteData] No scheduled job found with ID ${jobId} to cancel for workflowTrigger ${docToDelete._id}.`);
1181
+ }
1182
+ } else if (docToDelete._model === 'alert') {
1183
+ const jobId = `alert_${docToDelete._id}`;
1184
+ const scheduledJob = schedule.scheduledJobs[jobId];
1185
+ if (scheduledJob) {
1186
+ scheduledJob.cancel();
1187
+ logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted alert ${docToDelete._id}.`);
1188
+ }
1189
+ }
1190
+ // *** Fin de l'ajout ***
1191
+
1192
+ if (user) {
1193
+ // --- Logique existante pour gérer les relations ---
1194
+ const relatedModels = await modelsCollection.aggregate([
1195
+ {
1196
+ $match: {
1197
+ $and: [
1198
+ {"fields.relation": {$eq: docToDelete._model}}, // Utilise le modèle du document actuel
1199
+ {
1200
+ $and: [
1201
+ {_user: {$exists: true}},
1202
+ {
1203
+ $or: [
1204
+ {_user: {$eq: user._user}},
1205
+ {_user: {$eq: user.username}}
1206
+ ]
1207
+ }
1208
+ ]
1209
+ }
1210
+ ]
1211
+ }
1212
+ }
1213
+ ]).toArray();
1214
+
1215
+ for (const relatedModel of relatedModels) {
1216
+ let relsSet = {}, pullOps = {}, filterConditions = [];
1217
+ relatedModel.fields.forEach(f => {
1218
+ if (f.type === 'relation' && f.relation === docToDelete._model) {
1219
+ const fieldCondition = {[f.name]: docToDelete._id.toString()};
1220
+ filterConditions.push(fieldCondition); // Condition pour trouver les documents liés
1221
+
1222
+ if (f.multiple) {
1223
+ if (!pullOps.$pull) pullOps.$pull = {};
1224
+ pullOps.$pull[f.name] = docToDelete._id.toString();
1225
+ } else {
1226
+ relsSet[f.name] = null;
1227
+ }
1228
+ }
1229
+ });
1230
+
1231
+ if (filterConditions.length > 0) {
1232
+ const updateFilter = {
1233
+ $and: [
1234
+ {_user: {$exists: true}},
1235
+ {_model: relatedModel.name},
1236
+ {$or: [{_user: user._user}, {_user: user.username}]},
1237
+ {$or: filterConditions}
1238
+ ]
1239
+ };
1240
+
1241
+ const updateOps = {};
1242
+ if (Object.keys(relsSet).length > 0) updateOps.$set = relsSet;
1243
+ if (pullOps.$pull && Object.keys(pullOps.$pull).length > 0) updateOps.$pull = pullOps.$pull;
1244
+
1245
+ if (Object.keys(updateOps).length > 0) {
1246
+ const elementsToUpdate = await collection.find(updateFilter).toArray();
1247
+ const updateResult = await collection.updateMany(updateFilter, updateOps);
1248
+ logger.debug(`[deleteData] Updated relations in model ${relatedModel.name} referencing ${docToDelete._id}. Modified: ${updateResult.modifiedCount}`);
1249
+
1250
+ if (triggerWorkflow) {
1251
+ elementsToUpdate.forEach(e => {
1252
+ collection.findOne({_id: e._id}).then(updatedDoc => {
1253
+ if (updatedDoc) {
1254
+ triggerWorkflows(updatedDoc, user, 'DataEdited').catch(workflowError => {
1255
+ logger.error(`[deleteData] Async error triggering DataEdited workflow for ${updatedDoc._model} ID ${updatedDoc._id}:`, workflowError);
1256
+ });
1257
+ }
1258
+ });
1259
+ });
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ // --- Fin de la logique des relations ---
1266
+
1267
+ // Déclencher le workflow DataDeleted AVANT la suppression effective
1268
+ if (triggerWorkflow) {
1269
+ const prom = triggerWorkflows(docToDelete, user, 'DataDeleted').catch(workflowError => {
1270
+ logger.error(`[deleteData] Async error triggering DataDeleted workflow for ${docToDelete._model} ID ${docToDelete._id}:`, workflowError);
1271
+ });
1272
+
1273
+ if (waitForWorkflow) {
1274
+ await prom;
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ // Ajouter l'ID à la liste finale si la permission est accordée
1280
+ finalIdsToDelete.push(docToDelete._id);
1281
+
1282
+ } // Fin de la boucle sur documentsToDelete
1283
+
1284
+ // 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
1285
+ let deletedCount = 0;
1286
+ if (finalIdsToDelete.length > 0) {
1287
+ const result = await collection.deleteMany({
1288
+ _id: {$in: finalIdsToDelete}
1289
+ // Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
1290
+ });
1291
+ deletedCount = result.deletedCount;
1292
+ logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
1293
+ } else {
1294
+ logger.info(`[deleteData] No documents to delete for user ${user?.username} after permission checks or matching criteria.`);
1295
+ }
1296
+
1297
+ const res = {success: true, deletedCount}
1298
+ const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, {model: modelName, filter});
1299
+ await Event.Trigger("OnDataDeleted", "event", "user", {model: modelName, filter});
1300
+ return plugin || res;
1301
+
1302
+ } catch (error) {
1303
+ logger.error(`[deleteData] Error during deletion process for user ${user?.username}:`, error);
1304
+ // Renvoyer une structure d'erreur cohérente
1305
+ return ({
1306
+ success: false,
1307
+ error: error.message || "An unexpected error occurred during deletion.",
1308
+ statusCode: error.statusCode || 500
1309
+ });
1310
+ }
1311
+ }
1312
+ export const searchData = async (query, user) => {
1313
+ const {page, limit, sort, model, ids, timeout, pack} = query; // Les filtres de la requête (attention aux injections MongoDB !)
1314
+
1315
+ if (user && user.username !== 'demo' && isLocalUser(user) && (
1316
+ !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_" + model], user) ||
1317
+ await hasPermission(["API_SEARCH_DATA_NOT_" + model], user))) {
1318
+ throw new Error(i18n.t('api.permission.searchData'));
1319
+ }
1320
+
1321
+ const collection = await getCollectionForUser(user);
1322
+ const modelElement = await getModel(model, user);
1323
+
1324
+ const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
1325
+ return new ObjectId(m);
1326
+ });
1327
+ let l = Math.min(modelElement.maxRequestData || maxRequestData, limit ? parseInt(limit, 10) : maxRequestData);
1328
+ let p = parseInt(page, 10);
1329
+ let filter = query.filter || {};
1330
+
1331
+ let sortObj = {};
1332
+ sort?.split(',').forEach(s => {
1333
+ const v = s.split(':');
1334
+ sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
1335
+ })
1336
+ if (!sort) {
1337
+ sortObj = {[modelElement.fields[0]?.name || '_id']: ['datetime', 'date'].includes(modelElement.fields[0].type) ? -1 : 1};
1338
+ }
1339
+
1340
+ let i = 0;
1341
+ const f = {...filter};
1342
+
1343
+ let depthParam = Math.max(1, Math.min(maxFilterDepth, typeof (query.depth) === 'string' ? parseInt(query.depth) : (typeof (query.depth) === 'number' ? query.depth : 1)));
1344
+ let autoExpand = typeof (query.autoExpand) === 'undefined' || (typeof (query.autoExpand) === 'string' && ['1', 'true'].includes(query.autoExpand.toLowerCase()));
1345
+
1346
+ const recursiveLookup = async (model, data, depth = 1, already = [], parentPath = '') => {
1347
+
1348
+ if (depth > depthParam)
1349
+ return [];
1350
+
1351
+ let pipelines = [], pipelinesLookups = [];
1352
+ let modelElement;
1353
+ try {
1354
+ modelElement = await getModel(model, user);
1355
+ } catch (e) {
1356
+ return [];
1357
+ }
1358
+
1359
+ let dataRelationF = [], dataNoRelation = {};
1360
+ let dte = changeValue(data, '$find', (name, d, topLevel) => {
1361
+ if (autoExpand)
1362
+ depthParam++;
1363
+ const field = modelElement.fields.find(f => f.name === name);
1364
+ if (!field || !name)
1365
+ return {};
1366
+ if (field.type === "relation") {
1367
+ const dt = {
1368
+ '$ne': [
1369
+ {
1370
+ '$filter': {
1371
+ 'input': (depth === 1 ? "$" + name : "$this." + name),
1372
+ 'as': 'this',
1373
+ 'cond': d
1374
+ }
1375
+ }
1376
+ , []]
1377
+ };
1378
+ dataRelationF.push(dt);
1379
+ dataRelationF.push({"$ne": [(depth === 1 ? "$" + name : "$this." + name), null]})
1380
+ return {"$internal": {}};
1381
+ }
1382
+ dataNoRelation[name] = d;
1383
+ return {"$internal": d};
1384
+ });
1385
+
1386
+ dataNoRelation = changeValue(dte, '$internal', (name, d, topLevel) => {
1387
+ return d;
1388
+ });
1389
+
1390
+ for (let fi of modelElement.fields.sort((s1, s2) => {
1391
+ const v = s1.type === 'relation' ? 1 : 0;
1392
+ const v2 = s2.type === 'relation' ? 1 : 0;
1393
+
1394
+ const t1 = s1.type === 'calculated' ? 1 : 0;
1395
+ const t2 = s2.type === 'calculated' ? 1 : 0;
1396
+ return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
1397
+ })) {
1398
+
1399
+ // **Circular Reference Check:**
1400
+ if (already.includes(fi.relation)) {
1401
+ // Skip the lookup if we've already processed this relation in the current chain.
1402
+ console.warn(`Skipping circular reference to model: ${fi.relation}`);
1403
+ continue;
1404
+ }
1405
+ const relSort = {};
1406
+ if (fi.type === 'relation' && depthParam !== 1) {
1407
+ delete f[fi.name];
1408
+ if (sortObj[fi.name]) {
1409
+
1410
+ const sortColumn = await getModel(fi.relation, user);
1411
+ let t = sortColumn.fields.find(f => f.asMain)?.name;
1412
+ if (!t) {
1413
+ let t = sortColumn.fields?.find((f) => f.type === 'string_t')?.name;
1414
+ if (t)
1415
+ relSort['items' + i + '.' + t] = sortObj[fi.name];
1416
+ else {
1417
+
1418
+ t = sortColumn.fields?.find((f) => f.type === 'string')?.name;
1419
+ if (t) {
1420
+ relSort['items' + i + '.' + t] = sortObj[fi.name];
1421
+ }
1422
+ }
1423
+ } else {
1424
+ relSort['items' + i + '.' + t] = sortObj[fi.name];
1425
+ }
1426
+ if (t) {
1427
+ delete sortObj[fi.name];
1428
+ }
1429
+ }
1430
+
1431
+ // Création du lookup si l'expand est activé
1432
+ ++i;
1433
+ const lookup = {
1434
+ $lookup: {
1435
+ from: await getUserCollectionName(user),
1436
+ as: "items" + i,
1437
+ let: {
1438
+ dtid: {'$toString': '$_id'}, convertedId: '$' + fi.name
1439
+ },
1440
+ pipeline: [
1441
+ {
1442
+ $match: {
1443
+ $expr: {
1444
+ $and: [
1445
+ ...[
1446
+ {$eq: ["$_model", fi.relation]},
1447
+ {$eq: ["$_user", user.username]},
1448
+ fi.multiple ? {
1449
+ $in: [{$toString: "$_id"}, {
1450
+ $map: {
1451
+ input: {$ifNull: ["$$convertedId", []]}, // On utilise le tableau d'IDs, ou un tableau vide s'il est null
1452
+ as: "relationId",
1453
+ in: {$toString: "$$relationId"}
1454
+ }
1455
+ }]
1456
+ } : {
1457
+ $eq: [
1458
+ {$toString: "$_id"},
1459
+ {$convert: {input: '$$convertedId', to: "string", onError: ''}}
1460
+ ]
1461
+ }
1462
+ ]
1463
+ ]
1464
+ }
1465
+ }
1466
+ },
1467
+ {$limit: maxRelationsPerData}
1468
+ ]
1469
+ }
1470
+ };
1471
+
1472
+ pipelinesLookups.push(lookup);
1473
+ pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
1474
+
1475
+ // Construct the path for the current field
1476
+ const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
1477
+ fi.path = currentPath;
1478
+ pipelinesLookups.push(
1479
+ {
1480
+ $addFields: {
1481
+ [`${fi.name}`]: (fi.multiple ? "$items" + i : {
1482
+ $cond: {
1483
+ if: {$gt: [{$size: {$ifNull: ["$items" + i, []]}}, 0]},
1484
+ then: "$items" + i,
1485
+ else: null
1486
+ }
1487
+ })
1488
+ }
1489
+ }
1490
+ );
1491
+
1492
+ //found = true;
1493
+ pipelinesLookups.push(
1494
+ {$project: {['items' + i]: 0}}
1495
+ );
1496
+
1497
+ const nextAlready = [...already, fi.relation];
1498
+ if (Object.keys(relSort).length) {
1499
+ pipelinesLookups.push(
1500
+ {$sort: relSort}
1501
+ );
1502
+ }
1503
+
1504
+ let find = dte[fi.name] || [];
1505
+
1506
+ const rec = await recursiveLookup(fi.relation, find, depth + 1, nextAlready, currentPath);
1507
+
1508
+ if (rec.length) {
1509
+ lookup['$lookup']['pipeline'] = lookup['$lookup']['pipeline'].concat(rec);
1510
+ }
1511
+
1512
+ lookup['$lookup']['pipeline'].push(
1513
+ {$project: {_model: 0}}
1514
+ );
1515
+ lookup['$lookup']['pipeline'].push(
1516
+ {$project: {_user: 0}}
1517
+ );
1518
+
1519
+ } else if (fi.type === 'file') {
1520
+ // Logique pour enrichir un champ fichier unique
1521
+
1522
+ // Stage 1: Lookup file details from the 'files' collection
1523
+ pipelinesLookups.push({
1524
+ $lookup: {
1525
+ from: "files", // The global collection where file metadata is stored
1526
+ let: {fileGuid: '$' + fi.name}, // The GUID string from the current document's field
1527
+ pipeline: [
1528
+ {
1529
+ $match: {
1530
+ $expr: {
1531
+ $and: [
1532
+ {$eq: ['$guid', '$$fileGuid']},
1533
+ {$eq: ['$user', user.username]}
1534
+ ]
1535
+ }
1536
+ }
1537
+ },
1538
+ {$limit: 1} // GUIDs should be unique, so limit to 1
1539
+ ],
1540
+ as: fi.name + "_details_temp" // Temporary field to store the lookup result (an array)
1541
+ }
1542
+ });
1543
+
1544
+ // Stage 2: Replace the original GUID string with the fetched file object (or null if not found)
1545
+ pipelinesLookups.push({
1546
+ $addFields: {
1547
+ [fi.name]: {
1548
+ // $lookup returns an array, take the first element.
1549
+ // If lookup result is empty or null, set the field to null.
1550
+ $ifNull: [{$first: '$' + fi.name + "_details_temp"}, null]
1551
+ }
1552
+ }
1553
+ });
1554
+
1555
+ // Stage 3: Clean up the temporary lookup field
1556
+ pipelinesLookups.push({
1557
+ $project: {
1558
+ [fi.name + "_details_temp"]: 0
1559
+ }
1560
+ });
1561
+ } else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
1562
+ // This field (e.g., 'myImageGallery') stores an array of GUID strings: ["guid1", "guid2"]
1563
+ pipelinesLookups.push(
1564
+ {
1565
+ $lookup: {
1566
+ from: "files", // The global collection where file metadata is stored
1567
+ let: {localGuidsArray: '$' + fi.name}, // The array of GUID strings from the current document
1568
+ pipeline: [
1569
+ {
1570
+ $match: {
1571
+ $expr: {
1572
+ // Match documents in "files" collection where 'guid' is in the '$$localGuidsArray'
1573
+ $in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}] // Handle null or missing array
1574
+ }
1575
+ }
1576
+ }
1577
+ // Optional: Project only necessary fields from the "files" collection if needed
1578
+ // {
1579
+ // $project: {
1580
+ // _id: 0, // Exclude MongoDB's _id from the 'files' collection documents
1581
+ // // mainUser: 0, // Example: if you don't need these in the result
1582
+ // // user: 0,
1583
+ // // _model:0, // The _model "privateFile" might not be useful here
1584
+ // // Keep: guid, filename (as name), mimetype, size, timestamp etc.
1585
+ // }
1586
+ // }
1587
+ ],
1588
+ as: fi.name + "_details_temp" // Temporary field to store the array of matched file detail objects
1589
+ }
1590
+ },
1591
+ // The following $addFields and $project stages are what you had
1592
+ // in your fi.type === 'file' block, and they are correct for this array scenario.
1593
+ {
1594
+ $addFields: {
1595
+ [fi.name]: { // Remplacer le tableau de chaînes GUID par un tableau d'objets fichiers détaillés
1596
+ $ifNull: [ // Gérer le cas où le champ fi.name est null (original array of GUIDs)
1597
+ {
1598
+ $map: {
1599
+ input: '$' + fi.name, // Itérer sur le tableau original de chaînes GUID
1600
+ as: "originalGuidString", // Each element from the input array (a GUID string)
1601
+ in: {
1602
+ $let: {
1603
+ vars: {
1604
+ // Trouver le détail correspondant dans _details_temp par GUID
1605
+ matchedDetail: {
1606
+ $arrayElemAt: [
1607
+ {
1608
+ $filter: {
1609
+ input: '$' + fi.name + "_details_temp", // Use the result from the $lookup above
1610
+ as: "detailFile", // Each document from _details_temp
1611
+ cond: {$eq: ["$$detailFile.guid", "$$originalGuidString"]}
1612
+ }
1613
+ },
1614
+ 0 // Take the first match (GUIDs should be unique in "files")
1615
+ ]
1616
+ }
1617
+ },
1618
+ in: {
1619
+ $cond: {
1620
+ if: '$$matchedDetail', // Si des détails ont été trouvés
1621
+ then: '$$matchedDetail', // Utiliser l'objet détaillé complet
1622
+ else: { // Si aucun détail trouvé pour ce GUID (e.g., broken reference)
1623
+ guid: '$$originalGuidString', // Conserver le GUID original
1624
+ name: null, // Ou une valeur par défaut comme "Fichier inconnu"
1625
+ _error: "File details not found"
1626
+ // Ou simplement: '$$originalGuidString' si vous voulez juste garder la chaîne
1627
+ }
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+ }
1633
+ },
1634
+ [] // Si le champ original fi.name était null, le résultat est un tableau vide
1635
+ ]
1636
+ }
1637
+ }
1638
+ },
1639
+ {
1640
+ $project: { // Nettoyer le champ temporaire
1641
+ [fi.name + "_details_temp"]: 0
1642
+ }
1643
+ }
1644
+ );
1645
+ } else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
1646
+ const calcPipelineAbstract = fi.calculation.pipeline;
1647
+ const calcFinalFieldName = fi.calculation.final;
1648
+ const tempLookupsForThisCalcField = []; // Pour stocker les noms 'as' des lookups de CE champ calculé
1649
+
1650
+ // Ajouter les étapes $lookup définies par le calcul
1651
+ if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
1652
+ for (const lookupDef of calcPipelineAbstract.lookups) {
1653
+ // ... (votre logique existante de vérification de foreignModel et localField) ...
1654
+ // Assurez-vous que cette logique est robuste comme discuté précédemment.
1655
+ // Si une erreur se produit ici (foreignModel non trouvé, etc.),
1656
+ // vous ajoutez déjà un $addFields pour initialiser lookupDef.as à null/[]
1657
+ // et vous faites 'continue'. C'est bien.
1658
+
1659
+ // Si tout va bien, on construit le lookup :
1660
+ const targetCollectionName = await getUserCollectionName(user);
1661
+ const localFieldValueInPipeline = `$${lookupDef.localField}`;
1662
+
1663
+ // Vérification basique du localField (déjà présente dans votre code précédent)
1664
+ if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
1665
+ logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
1666
+ pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
1667
+ continue;
1668
+ }
1669
+
1670
+ const mongoLookupStage = {
1671
+ $lookup: {
1672
+ from: targetCollectionName,
1673
+ let: {local_val: localFieldValueInPipeline},
1674
+ pipeline: [
1675
+ {
1676
+ $match: {
1677
+ $expr: {
1678
+ $and: [
1679
+ {$eq: ["$_model", lookupDef.foreignModel]},
1680
+ {$eq: ["$_user", user.username]},
1681
+ lookupDef.isMultiple
1682
+ ? {$in: [{$toString: "$_id"}, {$ifNull: ["$$local_val", []]}]}
1683
+ : {$eq: [{$toString: "$_id"}, {$ifNull: ["$$local_val", null]}]}
1684
+ ]
1685
+ }
1686
+ }
1687
+ }
1688
+ // Optionnel: Projeter uniquement les champs nécessaires
1689
+ ],
1690
+ as: lookupDef.as
1691
+ }
1692
+ };
1693
+ pipelinesLookups.push(mongoLookupStage);
1694
+ tempLookupsForThisCalcField.push(lookupDef.as); // Suivre ce champ temporaire
1695
+
1696
+ if (!lookupDef.isMultiple) {
1697
+ pipelinesLookups.push({
1698
+ $unwind: {
1699
+ path: `$${lookupDef.as}`,
1700
+ preserveNullAndEmptyArrays: true
1701
+ }
1702
+ });
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ // Ajouter l'étape $addFields pour les calculs eux-mêmes
1708
+ if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
1709
+ const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({$addFields: {[m]: calcPipelineAbstract.addFields[m]}}));
1710
+ pipelinesLookups = pipelinesLookups.concat(addFields);
1711
+ }
1712
+
1713
+ // S'assurer que le champ final du calcul (calcFinalFieldName) est bien accessible
1714
+ // sous le nom du champ du modèle (fi.name).
1715
+ if (calcFinalFieldName !== fi.name) {
1716
+ pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
1717
+ }
1718
+
1719
+ // --- NOUVEAU : Supprimer les champs de lookup temporaires pour CE champ calculé ---
1720
+ if (tempLookupsForThisCalcField.length > 0) {
1721
+ const unsetProjection = {};
1722
+ for (const tempField of tempLookupsForThisCalcField) {
1723
+ // On peut supprimer tous les champs __calc_lookup_... car le CalculationBuilder
1724
+ // empêche que outputAlias (et donc calcFinalFieldName, et donc fi.name)
1725
+ // soit un de ces champs temporaires.
1726
+ unsetProjection[tempField] = 0; // 0 signifie supprimer/exclure le champ
1727
+ }
1728
+ if (Object.keys(unsetProjection).length > 0) {
1729
+ pipelinesLookups.push({$project: unsetProjection});
1730
+ }
1731
+ }
1732
+ } else if (fi.type === 'array') {
1733
+ // Handle array filtering here
1734
+ if (data[fi.name]) {
1735
+ pipelines.push({
1736
+ $match: {
1737
+ $expr: {
1738
+ $in: [data[fi.name], '$' + fi.name] // Check if the array contains the value
1739
+ }
1740
+ }
1741
+ });
1742
+ }
1743
+ } else if (data[fi.name]) {
1744
+ if (typeof (data[fi.name]) === 'string') {
1745
+ pipelines.push({
1746
+ $match: {
1747
+ $expr: {
1748
+ $eq: ['$' + fi.name, data[fi.name]]
1749
+ }
1750
+ }
1751
+ })
1752
+ }
1753
+ }
1754
+ }
1755
+
1756
+
1757
+ let addFields = [];
1758
+ /*modelElement.fields.forEach(field => {
1759
+ if( field.type==='relation' && !field.multiple && depthParam !== 1 && (dataRelationF.length)){
1760
+ addFields.push(
1761
+ {$addFields: {[`${field.name}`]: {$first: '$'+field.name }}}
1762
+ )
1763
+ }
1764
+ })*/
1765
+ return pipelines.concat([{$match: {'_pack': pack ? pack : {$exists: false}}}, {$match: {$expr: dataNoRelation}}], pipelinesLookups, addFields, [{$match: {$expr: {$and: dataRelationF}}}]);
1766
+ };
1767
+
1768
+ let pipelines = [];
1769
+ if (allIds.length) {
1770
+ const id = {$in: ["$_id", allIds.map(m => new ObjectId(m))]};
1771
+ pipelines.push({
1772
+ $match: {$expr: id}
1773
+ });
1774
+
1775
+ } else {
1776
+
1777
+ pipelines.push(
1778
+ {
1779
+ $match: {
1780
+ $expr: {
1781
+ $and: [{$eq: ["$_model", modelElement.name]},
1782
+ {$eq: ["$_user", user.username]}]
1783
+ }
1784
+ }
1785
+ }
1786
+ )
1787
+ }
1788
+
1789
+ pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, []));
1790
+ if (depthParam) {
1791
+ pipelines.push({$project: {_user: 0}});
1792
+ pipelines.push({$project: {_model: 0}});
1793
+ }
1794
+
1795
+ //console.log(util.inspect(pipelines, false, 29, true));
1796
+
1797
+ // 4. Exécuter la pipeline
1798
+ const ts = parseInt(timeout, 10) / 2.0 || searchRequestTimeout;
1799
+ const count = await collection.aggregate([...pipelines, {$count: "count"}]).maxTimeMS(ts).toArray();
1800
+ let prom = collection.aggregate(pipelines).maxTimeMS(ts);
1801
+
1802
+ if (Object.keys(sortObj).length > 0) {
1803
+ prom.sort(sortObj);
1804
+ }
1805
+ prom.skip(p ? (p - 1) * l : 0).limit(l);
1806
+ let data = await prom.toArray();
1807
+ data = await handleFields(modelElement, data, user);
1808
+
1809
+ const res = {data, count: count[0]?.count || 0};
1810
+ const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
1811
+ await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
1812
+ return plugin || res;
1813
+ }
1814
+ export const importData = async (options, files, user) => {
1815
+
1816
+ if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)) {
1817
+ return ({success: false, error: "API_IMPORT_DATA permission needed."});
1818
+ }
1819
+
1820
+ const file = files.file;
1821
+ const hasHeaders = options.hasHeaders ? options.hasHeaders === 'true' : true;
1822
+ const csvHeadersString = options.csvHeaders;
1823
+
1824
+ if (!file) {
1825
+ return ({success: false, error: "No file uploaded."});
1826
+ }
1827
+
1828
+ const importJobId = new ObjectId().toString();
1829
+ importJobs[importJobId] = {
1830
+ userId: user.username,
1831
+ status: 'pending',
1832
+ totalRecords: 0,
1833
+ processedRecords: 0,
1834
+ errors: [],
1835
+ jobId: importJobId // Inclure l'ID de la tâche dans son état
1836
+ };
1837
+
1838
+ const importJob = importJobs[importJobId];
1839
+ // Excuter le reste de la logique d'importation en arrière-plan
1840
+ (async () => {
1841
+ let fileProcessed = false;
1842
+ const importResults = {success: true, counts: {}, errors: []}; // Pour collecter les erreurs internes
1843
+
1844
+ try {
1845
+ const fileContent = fs.readFileSync(file.path);
1846
+ // --- DÉBUT DE LA MODIFICATION ---
1847
+ // La variable allProcessedData est maintenant déclarée à l'intérieur de la boucle
1848
+ // let allProcessedData = [];
1849
+ // let modelNameForImport = '';
1850
+ // --- FIN DE LA MODIFICATION ---
1851
+ if (!file.name) {
1852
+ throw new Error("No file provided.");
1853
+ }
1854
+ if (file.type === 'application/json' || file.name.endsWith('.json')) {
1855
+ fileProcessed = true;
1856
+ const jsonData = await runImportExportWorker('parse-json', {fileContent: fileContent.toString()});
1857
+
1858
+ // --- Logique d'importation des modèles (inchangée) ---
1859
+ if (jsonData.models && Array.isArray(jsonData.models)) {
1860
+ logger.info(`[Model Import] Found ${jsonData.models.length} models to import for user ${user.username}.`);
1861
+ const userModels = await modelsCollection.find({_user: user.username}).toArray();
1862
+ const userModelNames = userModels.map(m => m.name);
1863
+
1864
+ for (const modelToInstall of jsonData.models) {
1865
+ try {
1866
+ const modelName = modelToInstall.name;
1867
+ if (!modelName) {
1868
+ throw new Error("Model definition in import file is missing a 'name'.");
1869
+ }
1870
+
1871
+ if (userModelNames.includes(modelName)) {
1872
+ logger.debug(`[Model Import] Skipping '${modelName}': already exists for user.`);
1873
+ continue;
1874
+ }
1875
+
1876
+ const modelData = {...modelToInstall};
1877
+ delete modelData._id;
1878
+ modelData._user = user.username;
1879
+ modelData.locked = false;
1880
+ if (modelData.fields) {
1881
+ modelData.fields.forEach(f => {
1882
+ delete f._id;
1883
+ f.locked = false;
1884
+ });
1885
+ }
1886
+
1887
+ await validateModelStructure(modelData);
1888
+ await modelsCollection.insertOne(modelData);
1889
+ logger.info(`[Model Import] Successfully imported model '${modelName}' for user ${user.username}.`);
1890
+ } catch (e) {
1891
+ const errorMsg = `Failed to import model '${modelToInstall.name || 'N/A'}': ${e.message}`;
1892
+ logger.error(`[Model Import] ${errorMsg}`);
1893
+ importResults.errors.push(errorMsg);
1894
+ importResults.success = false;
1895
+ }
1896
+ }
1897
+ }
1898
+
1899
+ const dataToProcess = jsonData.data || jsonData;
1900
+ const modelsToProcess = [];
1901
+
1902
+ if (Array.isArray(dataToProcess)) {
1903
+ const modelNameForImport = options.model;
1904
+ if (!modelNameForImport) {
1905
+ importResults.errors.push("Model name is required in the request body when JSON is an array.");
1906
+ importResults.success = false;
1907
+ } else {
1908
+ modelsToProcess.push({name: modelNameForImport, data: dataToProcess});
1909
+ }
1910
+ } else if (typeof dataToProcess === 'object' && dataToProcess !== null) {
1911
+ for (const modelKey in dataToProcess) {
1912
+ if (modelKey === 'models') continue;
1913
+ if (Object.prototype.hasOwnProperty.call(dataToProcess, modelKey)) {
1914
+ if (Array.isArray(dataToProcess[modelKey])) {
1915
+ modelsToProcess.push({name: modelKey, data: dataToProcess[modelKey]});
1916
+ } else {
1917
+ const errorMsg = `Data for model '${modelKey}' in JSON object is not an array. Skipping.`;
1918
+ logger.warn(`[Import JSON Object] ${errorMsg}`);
1919
+ importResults.errors.push(errorMsg);
1920
+ importResults.success = false;
1921
+ }
1922
+ }
1923
+ }
1924
+ } else {
1925
+ importResults.errors.push("Invalid JSON file structure. Expecting an array of data, or an object where keys are model names and values are arrays of data.");
1926
+ importResults.success = false;
1927
+ }
1928
+
1929
+ if (modelsToProcess.length === 0 && importResults.errors.length === 0) {
1930
+ importResults.errors.push("No data found to process in JSON file.");
1931
+ importResults.success = false;
1932
+ }
1933
+
1934
+ // --- DÉBUT DE LA MODIFICATION PRINCIPALE ---
1935
+ // Mettre à jour le statut et le nombre total d'enregistrements avant la boucle
1936
+ if (importResults.success && modelsToProcess.length > 0) {
1937
+ importJobs[importJobId].totalRecords = modelsToProcess.reduce((acc, model) => acc + model.data.length, 0);
1938
+ importJobs[importJobId].status = 'processing';
1939
+ }
1940
+
1941
+ // Boucler sur CHAQUE modèle trouvé dans le fichier JSON
1942
+ for (const modelData of modelsToProcess) {
1943
+ const modelName = modelData.name;
1944
+ const dataArray = modelData.data;
1945
+
1946
+ if (dataArray.length === 0) {
1947
+ logger.info(`[Import Job ${importJobId}] Skipping model '${modelName}' as it has no data.`);
1948
+ continue;
1949
+ }
1950
+
1951
+ logger.info(`[Import Job ${importJobId}] Processing ${dataArray.length} records for model '${modelName}'.`);
1952
+
1953
+ try {
1954
+ const modelDef = await getModel(modelName, user);
1955
+ if (!modelDef) {
1956
+ throw new Error(i18n.t('api.model.notFound', {model: modelName}));
1957
+ }
1958
+
1959
+ const allProcessedData = convertDataTypes(dataArray, modelDef.fields, 'json');
1960
+
1961
+ // Logique de découpage en lots pour le modèle actuel
1962
+ for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
1963
+ const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
1964
+ try {
1965
+ const insertedIdsArray = await pushDataUnsecure(chunk, modelName, user, {});
1966
+ if (insertedIdsArray && insertedIdsArray.length > 0) {
1967
+ importJobs[importJobId].processedRecords += insertedIdsArray.length;
1968
+ logger.debug(`[Import Job ${importJobId}] Processed chunk for '${modelName}': ${insertedIdsArray.length} records. Total processed: ${importJobs[importJobId].processedRecords}`);
1969
+ sendSseToUser(user.username, {
1970
+ type: 'import_progress',
1971
+ job: importJobs[importJobId]
1972
+ });
1973
+ }
1974
+ } catch (chunkError) {
1975
+ console.log(chunkError.stack);
1976
+ const errorMsg = `[Import Job ${importJobId}] Error on chunk for model '${modelName}': ${chunkError.message}`;
1977
+ logger.error(errorMsg);
1978
+ importResults.errors.push(errorMsg);
1979
+ importJobs[importJobId].errors.push(errorMsg);
1980
+ importResults.success = false;
1981
+ }
1982
+
1983
+ if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
1984
+ await delay(IMPORT_CHUNK_DELAY_MS);
1985
+ }
1986
+ }
1987
+ importResults.counts[modelName] = (importResults.counts[modelName] || 0) + allProcessedData.length;
1988
+
1989
+ } catch (modelProcessingError) {
1990
+ const errorMsg = `[Import Job ${importJobId}] Failed to process model '${modelName}': ${modelProcessingError.message}`;
1991
+ logger.error(errorMsg);
1992
+ importResults.errors.push(errorMsg);
1993
+ importJobs[importJobId].errors.push(errorMsg);
1994
+ importResults.success = false;
1995
+ }
1996
+ }
1997
+ // --- FIN DE LA MODIFICATION PRINCIPALE ---
1998
+
1999
+ } else if (file.type === 'text/csv' || file.name.endsWith('.csv')) {
2000
+ // --- Logique CSV (inchangée, mais maintenant elle est séparée de la logique JSON) ---
2001
+ fileProcessed = true;
2002
+ const modelNameForImport = options.model;
2003
+ if (!modelNameForImport) {
2004
+ importResults.errors.push("Model name is required in the request body for CSV import.");
2005
+ importResults.success = false;
2006
+ } else {
2007
+ try {
2008
+ const modelDef = await getModel(modelNameForImport, user);
2009
+ if (!modelDef) {
2010
+ throw new Error(i18n.t('api.model.notFound', {model: modelNameForImport}));
2011
+ }
2012
+
2013
+ let userDefinedHeadersForMapping = [];
2014
+ if (!hasHeaders && csvHeadersString && typeof csvHeadersString === 'string') {
2015
+ userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
2016
+ }
2017
+
2018
+ const records = await runImportExportWorker('parse-csv', {
2019
+ fileContent: fileContent.toString(),
2020
+ options: {columns: hasHeaders}
2021
+ });
2022
+
2023
+ let datasToImport;
2024
+ if (!hasHeaders) {
2025
+ const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
2026
+ ? userDefinedHeadersForMapping
2027
+ : modelDef.fields.map(f => f.name);
2028
+
2029
+ datasToImport = records.map(recordRow => {
2030
+ const obj = {};
2031
+ if (Array.isArray(recordRow)) {
2032
+ recordRow.forEach((value, index) => {
2033
+ const targetModelFieldName = effectiveHeadersForMapping[index];
2034
+ if (targetModelFieldName && targetModelFieldName !== '') {
2035
+ if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
2036
+ obj[targetModelFieldName] = value;
2037
+ } else {
2038
+ logger.warn(`CSV Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
2039
+ }
2040
+ }
2041
+ });
2042
+ } else {
2043
+ Object.values(recordRow).forEach((value, index) => {
2044
+ const targetModelFieldName = effectiveHeadersForMapping[index];
2045
+ if (targetModelFieldName && targetModelFieldName !== '') {
2046
+ if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
2047
+ obj[targetModelFieldName] = value;
2048
+ } else {
2049
+ logger.warn(`CSV Import (!hasHeaders, object row): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
2050
+ }
2051
+ }
2052
+ });
2053
+ }
2054
+ return obj;
2055
+ });
2056
+ } else {
2057
+ datasToImport = records;
2058
+ }
2059
+ const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'csv');
2060
+
2061
+ // Logique de découpage en lots pour le CSV
2062
+ if (allProcessedData.length > 0) {
2063
+ importJobs[importJobId].totalRecords = allProcessedData.length;
2064
+ importJobs[importJobId].status = 'processing';
2065
+
2066
+ for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
2067
+ const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
2068
+ try {
2069
+ const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
2070
+ if (insertedIdsArray && insertedIdsArray.length > 0) {
2071
+ importJobs[importJobId].processedRecords += insertedIdsArray.length;
2072
+ sendSseToUser(user.username, {
2073
+ type: 'import_progress',
2074
+ job: importJobs[importJobId]
2075
+ });
2076
+ }
2077
+ } catch (chunkError) {
2078
+ const errorMsg = `[Import Job ${importJobId}] Error on CSV chunk: ${chunkError.message}`;
2079
+ logger.error(errorMsg, chunkError.stack);
2080
+ importResults.errors.push(errorMsg);
2081
+ importJobs[importJobId].errors.push(errorMsg);
2082
+ importResults.success = false;
2083
+ }
2084
+ if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
2085
+ await delay(IMPORT_CHUNK_DELAY_MS);
2086
+ }
2087
+ }
2088
+ importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
2089
+ }
2090
+
2091
+ } catch (modelProcessingError) {
2092
+ logger.error(`[Import CSV] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
2093
+ importResults.errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
2094
+ importResults.success = false;
2095
+ importJobs[importJobId].errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
2096
+ }
2097
+ }
2098
+ } else if (['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type) || file.name.endsWith('.xls') || file.name.endsWith('.xlsx')) {
2099
+ fileProcessed = true;
2100
+ const modelNameForImport = options.model;
2101
+ if (!modelNameForImport) {
2102
+ importResults.errors.push("Model name is required in the request body for Excel import.");
2103
+ importResults.success = false;
2104
+ } else {
2105
+ try {
2106
+ const modelDef = await getModel(modelNameForImport, user);
2107
+ if (!modelDef) {
2108
+ throw new Error(i18n.t('api.model.notFound', {model: modelNameForImport}));
2109
+ }
2110
+
2111
+ let datasToImport;
2112
+ let excelErrors = [];
2113
+
2114
+ // Cas 2: Pas d'en-têtes. On lit les lignes brutes et on les mappe.
2115
+ const rows = await readXlsxFile(fileContent, {sheet: 1});
2116
+
2117
+ let userDefinedHeadersForMapping = [];
2118
+ if (csvHeadersString && typeof csvHeadersString === 'string') {
2119
+ userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
2120
+ }
2121
+
2122
+ const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
2123
+ ? userDefinedHeadersForMapping
2124
+ : modelDef.fields.map(f => f.name);
2125
+
2126
+ datasToImport = rows.map(recordRow => {
2127
+ const obj = {};
2128
+ if (Array.isArray(recordRow)) {
2129
+ recordRow.forEach((value, index) => {
2130
+ const targetModelFieldName = effectiveHeadersForMapping[index];
2131
+ if (targetModelFieldName && targetModelFieldName !== '') {
2132
+ if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
2133
+ obj[targetModelFieldName] = value;
2134
+ } else {
2135
+ logger.warn(`Excel Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
2136
+ }
2137
+ }
2138
+ });
2139
+ }
2140
+ return obj;
2141
+ });
2142
+
2143
+ if (excelErrors.length > 0) {
2144
+ excelErrors.forEach(error => {
2145
+ const errorMsg = `Excel Import Error (Row ${error.row}, Column "${error.column}"): ${error.error}.`;
2146
+ logger.error(`[Import Job ${importJobId}] ${errorMsg}`);
2147
+ importResults.errors.push(errorMsg);
2148
+ importJobs[importJobId].errors.push(errorMsg);
2149
+ });
2150
+ importResults.success = false;
2151
+ }
2152
+
2153
+ if (datasToImport && datasToImport.length > 0) {
2154
+ const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'excel');
2155
+
2156
+ importJobs[importJobId].totalRecords = allProcessedData.length;
2157
+ importJobs[importJobId].status = 'processing';
2158
+
2159
+ for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
2160
+ const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
2161
+ try {
2162
+ const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
2163
+ if (insertedIdsArray && insertedIdsArray.length > 0) {
2164
+ importJobs[importJobId].processedRecords += insertedIdsArray.length;
2165
+ sendSseToUser(user.username, {
2166
+ type: 'import_progress',
2167
+ job: importJobs[importJobId]
2168
+ });
2169
+ }
2170
+ } catch (chunkError) {
2171
+ const errorMsg = `[Import Job ${importJobId}] Error on Excel chunk: ${chunkError.message}`;
2172
+ logger.error(errorMsg, chunkError.stack);
2173
+ importResults.errors.push(errorMsg);
2174
+ importJobs[importJobId].errors.push(errorMsg);
2175
+ importResults.success = false;
2176
+ }
2177
+ if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) await delay(IMPORT_CHUNK_DELAY_MS);
2178
+ }
2179
+ importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
2180
+ }
2181
+
2182
+ } catch (modelProcessingError) {
2183
+ logger.error(`[Import Excel] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
2184
+ importResults.errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
2185
+ importResults.success = false;
2186
+ importJobs[importJobId].errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
2187
+ }
2188
+ }
2189
+ } else {
2190
+ importResults.errors.push("Unsupported file type. Please upload a JSON or CSV file.");
2191
+ importResults.success = false;
2192
+ importJobs[importJobId].errors.push("Unsupported file type. Please upload a JSON or CSV file.");
2193
+ }
2194
+
2195
+ } catch (e) {
2196
+ logger.error("Import Error (Global):", e);
2197
+ importResults.success = false;
2198
+ importResults.errors.push(e.message || "An unexpected error occurred during import.");
2199
+ if (importJobs[importJobId]) {
2200
+ importJobs[importJobId].errors.push(e.message || "An unexpected error occurred during import.");
2201
+ }
2202
+ } finally {
2203
+ if (importJobs[importJobId]) {
2204
+ if (importResults.errors.length > 0) {
2205
+ importJobs[importJobId].status = 'failed';
2206
+ } else {
2207
+ importJobs[importJobId].status = 'completed';
2208
+ }
2209
+ sendSseToUser(user.username, {
2210
+ type: 'import_progress',
2211
+ job: importJobs[importJobId]
2212
+ });
2213
+ }
2214
+ if (file && file.path && fs.existsSync(file.path)) {
2215
+ fs.unlinkSync(file.path);
2216
+ }
2217
+ }
2218
+ })().catch(error => {
2219
+ logger.error(`Unhandled error in background import job ${importJobId}:`, error);
2220
+ if (importJobs[importJobId]) {
2221
+ importJobs[importJobId].status = 'failed';
2222
+ importJobs[importJobId].errors.push(error.message || "An unhandled error occurred in background process.");
2223
+ sendSseToUser(user.username, {
2224
+ type: 'import_progress',
2225
+ job: importJobs[importJobId]
2226
+ });
2227
+ }
2228
+ });
2229
+ return ({success: true, message: "Import initiated. Check progress via SSE.", job: importJob});
2230
+ }
2231
+ export const exportData = async (options, user) => {
2232
+ // Extract parameters from request body and query
2233
+ const {models, ids, filter = {}, depth, lang} = options;
2234
+ const userId = getUserId(user);
2235
+
2236
+ const effectiveMaxDepth = maxExportCount ?? maxFilterDepth; // Use defined constant or fallback
2237
+ i18n.changeLanguage(lang);
2238
+
2239
+ // --- Input Validation ---
2240
+ if (!Array.isArray(models) || models.length === 0) {
2241
+ return {success: false, error: i18n.t('api.export.error.noModels', 'Models array is required.')};
2242
+ }
2243
+ const parsedDepth = parseInt(depth, 10);
2244
+ if (isNaN(parsedDepth) || parsedDepth < 0 || parsedDepth > effectiveMaxDepth) {
2245
+ return {
2246
+ success: false,
2247
+ error: i18n.t('api.export.error.invalidDepth', `Invalid depth parameter. Must be between 0 and ${effectiveMaxDepth}.`, {maxDepth: effectiveMaxDepth})
2248
+ };
2249
+ }
2250
+ if (ids && !Array.isArray(ids)) {
2251
+ return {
2252
+ success: false,
2253
+ error: i18n.t('api.export.error.invalidIdsType', 'ids parameter must be an array if provided.')
2254
+ };
2255
+ }
2256
+ if (ids && !ids.every(id => typeof id === 'string' || isObjectId(id))) { // Allow string or ObjectId format
2257
+ return {
2258
+ success: false,
2259
+ error: i18n.t('api.export.error.invalidIdsContent', 'ids parameter must contain valid identifiers (strings or ObjectIds).')
2260
+ };
2261
+ }
2262
+
2263
+ const exportResults = {};
2264
+ let totalDocsFetched = 0;
2265
+ const errors = [];
2266
+
2267
+ let modelsToExport = [];
2268
+
2269
+ // --- Permissions & Data Fetching Loop ---
2270
+ for (const modelName of models) {
2271
+ if (totalDocsFetched >= maxExportCount) {
2272
+ console.warn(`Export limit of ${maxExportCount} documents reached before processing model ${modelName}.`);
2273
+ errors.push(i18n.t('api.export.error.limitReached', 'Export document limit reached.', {limit: maxExportCount}));
2274
+ break; // Stop fetching more models if limit is hit early
2275
+ }
2276
+
2277
+ try {
2278
+ // Check permission to search/export data for this model
2279
+ // Using API_SEARCH_DATA as a proxy, adjust if a specific export permission exists
2280
+ // Assuming checkPermission is adapted or hasPermission is used directly
2281
+ // Note: Your original code used checkPermission, but data.js has hasPermission. Adjust as needed.
2282
+ // Example using hasPermission:
2283
+ // if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user)) {
2284
+ // Example using checkPermission (if it exists and works similarly):
2285
+ if (isLocalUser(user) && !(isDemoUser(user) && Config.Get("useDemoAccounts")) && !(await hasPermission('API_EXPORT_DATA', user))) { // Adapt this line based on your actual permission function
2286
+ console.warn(`User ${userId} lacks permission to search/export model ${modelName}`);
2287
+ errors.push(i18n.t('api.permission.searchData', 'Cannot search data from the API') + ` (${modelName})`);
2288
+ continue; // Skip this model
2289
+ }
2290
+
2291
+ // Fetch model definition securely (needed by searchData internally anyway)
2292
+ // getModel might throw if not found, handle this
2293
+ try {
2294
+ const mod = await getModel(modelName, user);
2295
+ modelsToExport.push(mod);
2296
+ } catch (modelError) {
2297
+ console.warn(`Model ${modelName} not found or not accessible for user ${userId}: ${modelError.message}`);
2298
+ errors.push(i18n.t('api.model.notFound', 'Model {{model}} not found.', {model: modelName}));
2299
+ continue; // Skip this model
2300
+ }
2301
+
2302
+
2303
+ // Construct the query filter for the current model
2304
+ let modelSpecificFilter = {...(filter[modelName] || {})}; // Use model-specific filter from request if provided
2305
+ // _user filter is handled internally by searchData based on the user object passed
2306
+
2307
+ if (ids && ids.length > 0)
2308
+ modelSpecificFilter = {$in: ['$_id', ids]};
2309
+
2310
+ // Calculate remaining limit for this model
2311
+ const remainingLimit = maxExportCount - totalDocsFetched;
2312
+
2313
+ // --- Fetch Data using searchData ---
2314
+ const searchParams = {
2315
+ model: modelName,
2316
+ filter: modelSpecificFilter,
2317
+ depth: parsedDepth,
2318
+ limit: remainingLimit
2319
+ };
2320
+
2321
+ const {data: resultData, count} = await searchData(searchParams, user);
2322
+
2323
+ if (resultData && resultData.length > 0) {
2324
+ exportResults[modelName] = resultData;
2325
+ totalDocsFetched += resultData.length;
2326
+ logger.debug(`Fetched ${resultData.length} documents for model ${modelName}. Total: ${totalDocsFetched}`);
2327
+ } else {
2328
+ logger.debug(`No documents found for model ${modelName} with the given criteria.`);
2329
+ }
2330
+
2331
+ } catch (modelError) {
2332
+ // Catch errors specific to processing this model (e.g., from searchData)
2333
+ console.error(`Error exporting model ${modelName}:`, modelError);
2334
+ errors.push(i18n.t('api.export.error.modelError', 'Error exporting model {{model}}.', {model: modelName}) + ` (${modelError.message})`);
2335
+ }
2336
+ } // End of loop through models
2337
+
2338
+ // --- Prepare and Send Response ---
2339
+ if (Object.keys(exportResults).length === 0) {
2340
+ const finalError = errors.length > 0 ? errors.join('; ') : i18n.t('api.data.noExportData', 'No data found for the specified criteria or permissions denied.');
2341
+ // Use 404 if no data found/accessible, 400 if only errors occurred but no data attempt was possible
2342
+ const statusCode = errors.length > 0 && totalDocsFetched === 0 ? 400 : 404;
2343
+ return {success: false, error: finalError};
2344
+ }
2345
+
2346
+ // Include errors in the response if any occurred but some data was fetched
2347
+ if (errors.length > 0) {
2348
+ exportResults._exportErrors = errors;
2349
+ }
2350
+
2351
+ const res = {success: true, data: exportResults, models: modelsToExport};
2352
+ const plugin = await Event.Trigger("OnDataExported", "event", "system", engine, exportResults, modelsToExport);
2353
+ await Event.Trigger("OnDataExported", "event", "user", plugin?.exportResults || exportResults, plugin?.modelsToExport || modelsToExport);
2354
+ return plugin || res;
2355
+ }
2356
+
2357
+ /**
2358
+ * Installs pack models and data for a user or globally.
2359
+ * Can accept either a pack ID (to install from database) or a direct pack JSON object.
2360
+ *
2361
+ * @param {object} logger - Logger instance
2362
+ * @param {string|object} packIdentifier - Either pack ID (string) or pack JSON object
2363
+ * @param {object|null} user - User object (if installing for user) or null (for global install)
2364
+ * @param {string} [lang='en'] - Language code for localized data
2365
+ * @returns {Promise<{success: boolean, summary: object, errors: Array, modifiedCount: number}>}
2366
+ */
2367
+ export async function installPack(packIdentifier, user = null, lang = 'en', options = {}) {
2368
+ let pack;
2369
+ const packsCollection = getCollection('packs');
2370
+
2371
+ // Determine if we're working with an ID or direct pack object
2372
+ if (typeof packIdentifier === 'string') {
2373
+ let p;
2374
+ try {
2375
+ p = new ObjectId(packIdentifier);
2376
+ } catch (e) {
2377
+ p = packIdentifier;
2378
+ }
2379
+ // Existing behavior - fetch from database
2380
+ pack = await packsCollection.findOne({$and: [{_user: {$exists: false}}, {private: false}, {$or: [{_id: p}, {name: packIdentifier}]}]});
2381
+ if (!pack) {
2382
+ throw new Error(`Pack with ID ${packIdentifier} not found.`);
2383
+ }
2384
+ } else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
2385
+ // New behavior - use provided pack object directly
2386
+ pack = packIdentifier;
2387
+
2388
+ // Validate basic pack structure
2389
+ if (!pack.name || (!pack.models && !pack.data)) {
2390
+ throw new Error('Invalid pack structure - must contain at least name and models or data');
2391
+ }
2392
+ } else {
2393
+ throw new Error('Invalid pack identifier - must be either pack ID string or pack object');
2394
+ }
2395
+
2396
+ const username = user ? user.username : null;
2397
+ const logPrefix = username
2398
+ ? `Installing pack '${pack.name}' for user '${username}'`
2399
+ : `Installing pack '${pack.name}' globally`;
2400
+
2401
+ logger.info(`--- ${logPrefix} ---`);
2402
+
2403
+ const summary = {
2404
+ models: {installed: [], skipped: [], failed: []},
2405
+ datas: {inserted: 0, updated: 0, skipped: 0, failed: 0}
2406
+ };
2407
+ const errors = [];
2408
+ const collection = user ? await getCollectionForUser(user) : getCollection('data');
2409
+ const tempIdToNewIdMap = {};
2410
+ const linkCache = new Map();
2411
+
2412
+ // --- PHASE 1: MODEL INSTALLATION ---
2413
+ if (Array.isArray(pack.models)) {
2414
+ // For user installs, check existing models
2415
+ const existingModels = user
2416
+ ? await modelsCollection.find({_user: username}).toArray()
2417
+ : await modelsCollection.find({_user: {$exists: false}}).toArray();
2418
+
2419
+ console.log("EXISTING", existingModels);
2420
+
2421
+ const existingModelNames = existingModels.map(m => m.name);
2422
+
2423
+ for (const modelOrName of pack.models) {
2424
+ try {
2425
+ const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name;
2426
+ if (!modelName) throw new Error('Model definition in pack is missing a name.');
2427
+
2428
+ if (existingModelNames.includes(modelName)) {
2429
+ logger.debug(`[Model Install] Skipping '${modelName}': already exists`);
2430
+ summary.models.skipped.push(modelName);
2431
+ continue;
2432
+ }
2433
+
2434
+ const modelToInstall = typeof modelOrName === 'string'
2435
+ ? await modelsCollection.findOne({name: modelName, _user: {$exists: false}})
2436
+ : {...modelOrName};
2437
+
2438
+ if (!modelToInstall) {
2439
+ throw new Error(`Model '${modelName}' not found in shared models`);
2440
+ }
2441
+
2442
+ // Prepare model for installation
2443
+ const preparedModel = {...modelToInstall};
2444
+ if (user) preparedModel._user = username;
2445
+ delete preparedModel._id;
2446
+ preparedModel.locked = false;
2447
+
2448
+ if (preparedModel.fields) {
2449
+ preparedModel.fields.forEach(f => f.locked = false);
2450
+ }
2451
+
2452
+ await validateModelStructure(preparedModel);
2453
+ await modelsCollection.insertOne(preparedModel);
2454
+ summary.models.installed.push(modelName);
2455
+
2456
+ } catch (e) {
2457
+ const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name || 'unknown';
2458
+ errors.push(`Failed to install model '${modelName}': ${e.message}`);
2459
+ summary.models.failed.push(modelName);
2460
+ }
2461
+ }
2462
+ }
2463
+
2464
+ // --- PHASE 2: DATA INSTALLATION ---
2465
+ const dataToInstall = {...pack.data?.all, ...pack.data?.[lang]};
2466
+ if (!dataToInstall || Object.keys(dataToInstall).length === 0) {
2467
+ logger.warn(`Pack '${pack.name}' has no data to install.`);
2468
+ return {success: false, summary, errors, modifiedCount: 0};
2469
+ }
2470
+
2471
+ // Process link references (same as original)
2472
+ const linkQueue = [];
2473
+ for (const modelName in dataToInstall) {
2474
+ if (Array.isArray(dataToInstall[modelName])) {
2475
+ for (const docSource of dataToInstall[modelName]) {
2476
+ const tempId = new ObjectId().toString();
2477
+ docSource._temp_pack_id = tempId;
2478
+
2479
+ for (const fieldName in docSource) {
2480
+ if (isPlainObject(docSource[fieldName]) && docSource[fieldName].$link) {
2481
+ linkQueue.push({
2482
+ sourceTempId: tempId,
2483
+ sourceModelName: modelName,
2484
+ fieldName,
2485
+ linkSelector: docSource[fieldName].$link
2486
+ });
2487
+ }
2488
+ }
2489
+ }
2490
+ }
2491
+ }
2492
+
2493
+ // --- PASS 1: BATCH INSERTION ---
2494
+ logger.info("[Pack Install] Starting Pass 1: Batch Insertion & ID Mapping");
2495
+ for (const modelName in dataToInstall) {
2496
+ if (!Array.isArray(dataToInstall[modelName])) continue;
2497
+
2498
+ const documents = dataToInstall[modelName];
2499
+ if (documents.length === 0) continue;
2500
+
2501
+ const docsToInsert = [];
2502
+ const modelDefForHash = await getModel(modelName, user);
2503
+
2504
+ for (const docSource of documents) {
2505
+ let docForInsert = {...docSource};
2506
+
2507
+ // Clear $link fields for first pass
2508
+ for (const key in docForInsert) {
2509
+ if (isPlainObject(docForInsert[key]) && docForInsert[key].$link) {
2510
+ docForInsert[key] = null;
2511
+ }
2512
+ }
2513
+
2514
+ const tempId = docForInsert._temp_pack_id;
2515
+ delete docForInsert._id;
2516
+ delete docForInsert._temp_pack_id;
2517
+
2518
+ if (user) docForInsert._user = username;
2519
+ docForInsert._model = modelName;
2520
+ docForInsert._hash = getFieldValueHash(modelDefForHash, docForInsert);
2521
+
2522
+ // Check for existing document
2523
+ const existingQuery = {
2524
+ _hash: docForInsert._hash,
2525
+ _model: modelName
2526
+ };
2527
+ if (user) existingQuery._user = username;
2528
+
2529
+ const existingDoc = await collection.findOne(existingQuery, {projection: {_id: 1}});
2530
+ if (existingDoc) {
2531
+ tempIdToNewIdMap[tempId] = existingDoc._id;
2532
+ summary.datas.skipped++;
2533
+ } else {
2534
+ docForInsert._temp_pack_id_for_mapping = tempId;
2535
+ docsToInsert.push(docForInsert);
2536
+ }
2537
+ }
2538
+
2539
+ if (docsToInsert.length > 0) {
2540
+ try {
2541
+ const finalDocsToInsert = docsToInsert.map(d => {
2542
+ const doc = {...d};
2543
+ delete doc._temp_pack_id_for_mapping;
2544
+ return doc;
2545
+ });
2546
+
2547
+ const result = await collection.insertMany(finalDocsToInsert, {ordered: false});
2548
+ summary.datas.inserted += result.insertedCount;
2549
+
2550
+ docsToInsert.forEach((doc, index) => {
2551
+ if (result.insertedIds[index]) {
2552
+ tempIdToNewIdMap[doc._temp_pack_id_for_mapping] = result.insertedIds[index];
2553
+ }
2554
+ });
2555
+ } catch (e) {
2556
+ summary.datas.failed += docsToInsert.length;
2557
+ errors.push(`Error inserting batch for ${modelName}: ${e.message}`);
2558
+ logger.error(`[Pack Install] Error on insertMany for model ${modelName}:`, e);
2559
+ }
2560
+ }
2561
+ }
2562
+
2563
+ // --- PASS 2: REFERENCE LINKING ---
2564
+ logger.info(`[Pack Install] Starting Pass 2: Linking ${linkQueue.length} references`);
2565
+ for (const linkOp of linkQueue) {
2566
+ const {sourceTempId, sourceModelName, fieldName, linkSelector} = linkOp;
2567
+ const sourceId = tempIdToNewIdMap[sourceTempId];
2568
+
2569
+ if (!sourceId) {
2570
+ logger.warn(`[LINK FAILED] Could not find newly inserted document for temp ID ${sourceTempId}. Skipping link.`);
2571
+ continue;
2572
+ }
2573
+
2574
+ try {
2575
+ const targetModelName = linkSelector._model;
2576
+ delete linkSelector._model;
2577
+
2578
+ const sourceModelDef = await getModel(sourceModelName, user);
2579
+ const fieldDef = sourceModelDef.fields.find(f => f.name === fieldName);
2580
+
2581
+ if (!fieldDef) {
2582
+ logger.warn(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
2583
+ errors.push(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
2584
+ summary.datas.failed++;
2585
+ continue;
2586
+ }
2587
+
2588
+ // Search for target documents
2589
+ const {data: targetDocs} = await searchData(
2590
+ {model: targetModelName, filter: linkSelector},
2591
+ user
2592
+ );
2593
+
2594
+ if (!targetDocs || targetDocs.length === 0) {
2595
+ const errorMsg = `[LINK FAILED] No target found for ${JSON.stringify(linkSelector)}`;
2596
+ logger.warn(errorMsg);
2597
+ errors.push(errorMsg);
2598
+ summary.datas.failed++;
2599
+ continue;
2600
+ }
2601
+
2602
+ // Update source document with reference
2603
+ const valueToSet = fieldDef.multiple
2604
+ ? targetDocs.map(d => d._id.toString())
2605
+ : targetDocs[0]._id.toString();
2606
+
2607
+ await collection.updateOne(
2608
+ {_id: sourceId},
2609
+ {$set: {[fieldName]: valueToSet}}
2610
+ );
2611
+ summary.datas.updated++;
2612
+
2613
+ } catch (e) {
2614
+ const errorMsg = `[LINK CRITICAL] Error linking ${sourceModelName}.${fieldName}: ${e.message}`;
2615
+ logger.error(errorMsg, e.stack);
2616
+ errors.push(errorMsg);
2617
+ summary.datas.failed++;
2618
+ }
2619
+ }
2620
+
2621
+ if (options.installForUser && user?.username) {
2622
+ if (pack.name)
2623
+ await packsCollection.deleteOne({name: pack.name, _user: user.username});
2624
+ logger.info(`--- Creating pack '${pack.name}' for user... ---`);
2625
+ const packToCreate = {...pack, _id: undefined, private: true, _user: user.username};
2626
+ await packsCollection.insertOne(packToCreate);
2627
+ }
2628
+
2629
+ // Trigger event only if pack came from database (original behavior)
2630
+ if (typeof packIdentifier === 'string') {
2631
+ await Event.Trigger("OnPackInstalled", "event", "system", pack);
2632
+ }
2633
+
2634
+ const modifiedCount = summary.datas.inserted + summary.datas.updated;
2635
+ logger.info(`--- ${logPrefix} completed ---`);
2636
+ return {
2637
+ success: errors.length === 0,
2638
+ summary,
2639
+ errors,
2640
+ modifiedCount
2641
+ };
2642
+ }
2643
+
2644
+ export const installAllPacks = async () => {
2645
+ const packs = await getAllPacks();
2646
+ await packsCollection.deleteMany({_user: {$exists: false}});
2647
+ await packsCollection.insertMany(packs.map(p => ({...p, private: false})));
2648
+ }
2649
+ /**
2650
+ * Gère les traductions spécifiques à l'utilisateur et traite les données de manière récursive
2651
+ * pour l'anonymisation et la résolution des relations.
2652
+ *
2653
+ * Cette fonction est le point d'entrée principal. Elle charge temporairement les traductions
2654
+ * d'un utilisateur, traite les données, puis décharge les traductions pour garantir
2655
+ * qu'une requête n'affecte pas les autres.
2656
+ *
2657
+ * @param {object} model - La définition du modèle pour les données actuelles.
2658
+ * @param {object|Array<object>|null} data - Les données à traiter (un objet, un tableau ou null).
2659
+ * @param {object} user - L'objet utilisateur effectuant la requête.
2660
+ * @param {boolean} [isRecursiveCall=false] - Un drapeau interne pour éviter de recharger les traductions lors des appels récursifs.
2661
+ * @returns {Promise<object|Array<object>|null>} - Les données traitées, dans le même format que l'entrée.
2662
+ */
2663
+ const handleFields = async (model, data, user, isRecursiveCall = false) => {
2664
+ // Détermine si l'entrée était un tableau pour retourner le même format.
2665
+ const wasArray = Array.isArray(data);
2666
+ // Normalise les données en tableau pour un traitement unifié. Gère le cas où data est null/undefined.
2667
+ const dataArray = wasArray ? data : (data ? [data] : []);
2668
+
2669
+ if (dataArray.length === 0) {
2670
+ return wasArray ? [] : null;
2671
+ }
2672
+
2673
+ // Fonction interne pour traiter les données. Appelée après la gestion des traductions.
2674
+ const _processItems = async (items) => {
2675
+ const canRead = !isLocalUser(user) || await hasPermission(["API_ADMIN", "API_DEANONYMIZED", "API_DEANONYMIZED_" + model.name], user);
2676
+
2677
+ for (const item of items) {
2678
+ if (!item) continue;
2679
+
2680
+ if (item['_id']) {
2681
+ item['_id'] = item['_id'].toString();
2682
+ }
2683
+
2684
+ for (const field of model.fields) {
2685
+ const fieldName = field.name;
2686
+ const fieldValue = item[fieldName];
2687
+
2688
+ if (fieldValue === undefined) continue;
2689
+
2690
+ // 1. Anonymisation des champs si nécessaire
2691
+ if (field.anonymized && !canRead && dataTypes[field.type]?.anonymize) {
2692
+ item[fieldName] = dataTypes[field.type].anonymize(fieldValue, field, getObjectHash({id: item._id}));
2693
+ }
2694
+
2695
+ if (field.type === 'string_t') {
2696
+ item[fieldName] = {key: fieldValue, value: i18n.t(fieldValue, fieldValue)};
2697
+ }
2698
+
2699
+ // 2. Résolution récursive des relations
2700
+
2701
+ if (field.type === 'relation' && fieldValue) {
2702
+ try {
2703
+ const relatedModel = await getModel(field.relation, user);
2704
+ // Appel récursif à la fonction principale, en signalant que ce n'est pas l'appel initial.
2705
+ item[fieldName] = await handleFields(relatedModel, fieldValue, user, true);
2706
+ if (!field.multiple && Array.isArray(item[fieldName]) && item[fieldName].length <= 1) {
2707
+ item[fieldName] = item[fieldName][0] || null;
2708
+ }
2709
+ } catch (e) {
2710
+ logger.warn(`Impossible de traiter la relation pour le champ '${fieldName}' du modèle '${model.name}'. Erreur: ${e.message}`);
2711
+ // En cas d'erreur (ex: modèle de relation introuvable), on conserve la valeur originale (probablement un ID).
2712
+ }
2713
+ }
2714
+ }
2715
+ }
2716
+ return items;
2717
+ };
2718
+
2719
+ // Si c'est l'appel initial (non récursif) et qu'un utilisateur est fourni, on gère les traductions.
2720
+ if (!isRecursiveCall && user?.username) {
2721
+ const lang = user.lang || 'en'; // Utilise la langue définie par le middleware, sinon 'en'.
2722
+ let originalTranslations = null;
2723
+ let userTranslationsLoaded = false;
2724
+
2725
+
2726
+ try {
2727
+ const coll = await getCollectionForUser(user);
2728
+ // 1. Récupérer l'ID du document de langue de l'utilisateur pour la langue actuelle.
2729
+ const userLangDoc = await coll.findOne({
2730
+ _model: 'lang',
2731
+ code: lang,
2732
+ _user: user.username
2733
+ });
2734
+
2735
+ if (userLangDoc) {
2736
+ // 2. Récupérer les traductions de l'utilisateur pour cette langue.
2737
+ const userTranslationsArray = await coll.find({
2738
+ _model: 'translation',
2739
+ _user: user.username,
2740
+ lang: userLangDoc._id.toString()
2741
+ }).toArray();
2742
+
2743
+ if (userTranslationsArray.length > 0) {
2744
+ // 3. Préparer le "bundle" de ressources pour i18n.
2745
+ const newResourceBundle = userTranslationsArray.reduce((acc, tr) => {
2746
+ if (tr.key && tr.value) {
2747
+ acc[tr.key] = tr.value;
2748
+ }
2749
+ return acc;
2750
+ }, {});
2751
+
2752
+
2753
+ // 4. Charger temporairement les traductions de l'utilisateur.
2754
+ if (Object.keys(newResourceBundle).length > 0) {
2755
+ // Sauvegarder les traductions originales si elles existent
2756
+ if (i18n.store.data[lang] && i18n.store.data[lang].translation) {
2757
+ originalTranslations = {...i18n.store.data[lang].translation};
2758
+ }
2759
+ // Ajoute/remplace les clés de traduction pour la langue et le namespace courants.
2760
+ i18n.addResourceBundle(lang, 'translation', newResourceBundle, true, true);
2761
+ userTranslationsLoaded = true;
2762
+ logger.debug(`Chargement de ${userTranslationsArray.length} traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
2763
+ }
2764
+ }
2765
+ }
2766
+
2767
+ // 5. Traiter les données avec les traductions (personnalisées ou par défaut).
2768
+ const processedData = await _processItems(dataArray);
2769
+ return wasArray ? processedData : processedData[0];
2770
+ } finally {
2771
+ // 6. Nettoyage : décharger les traductions de l'utilisateur et restaurer les originales.
2772
+ // Ce bloc s'exécute toujours, même en cas d'erreur, garantissant l'isolation des requêtes.
2773
+ if (userTranslationsLoaded) {
2774
+ // Supprime le namespace temporaire.
2775
+ i18n.removeResourceBundle(lang, 'translation');
2776
+ logger.debug(`Déchargement des traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
2777
+
2778
+ // Restaure les traductions originales si elles avaient été sauvegardées.
2779
+ if (originalTranslations) {
2780
+ i18n.addResourceBundle(lang, 'translation', originalTranslations, true, true);
2781
+ logger.debug(`Restauration des traductions originales pour la langue '${lang}'.`);
2782
+ }
2783
+ }
2784
+ }
2785
+ } else {
2786
+ // C'est un appel récursif ou il n'y a pas d'utilisateur, on traite donc directement les données.
2787
+ const processedData = await _processItems(dataArray);
2788
+ return wasArray ? processedData : processedData[0];
2789
+ }
2790
+ };