data-primals-engine 1.4.1 → 1.4.3

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 (49) hide show
  1. package/README.md +37 -22
  2. package/client/package-lock.json +33 -0
  3. package/client/package.json +1 -0
  4. package/client/src/App.scss +12 -4
  5. package/client/src/AssistantChat.jsx +48 -5
  6. package/client/src/AssistantChat.scss +0 -1
  7. package/client/src/ChartConfigModal.jsx +34 -9
  8. package/client/src/ConditionBuilder.jsx +1 -1
  9. package/client/src/ConditionBuilder2.jsx +2 -1
  10. package/client/src/Dashboard.jsx +396 -349
  11. package/client/src/DashboardChart.jsx +91 -12
  12. package/client/src/DataEditor.jsx +376 -368
  13. package/client/src/DataLayout.jsx +3 -2
  14. package/client/src/DataTable.jsx +60 -31
  15. package/client/src/Field.jsx +1788 -1782
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/ModelCreatorField.jsx +1 -0
  18. package/client/src/RelationField.jsx +2 -2
  19. package/client/src/constants.js +2 -2
  20. package/client/src/contexts/UIContext.jsx +5 -2
  21. package/client/src/filter.js +0 -155
  22. package/client/src/translations.js +16828 -16750
  23. package/package.json +10 -2
  24. package/src/config.js +2 -2
  25. package/src/constants.js +262 -4
  26. package/src/core.js +21 -3
  27. package/src/defaultModels.js +12 -12
  28. package/src/engine.js +7 -1
  29. package/src/events.js +4 -3
  30. package/src/filter.js +272 -260
  31. package/src/i18n.js +187 -177
  32. package/src/middlewares/middleware-mongodb.js +3 -1
  33. package/src/modules/assistant/assistant.js +131 -42
  34. package/src/modules/bucket.js +3 -2
  35. package/src/modules/data/data.backup.js +374 -0
  36. package/src/modules/data/data.core.js +2 -1
  37. package/src/modules/data/data.history.js +11 -8
  38. package/src/modules/data/data.js +190 -4551
  39. package/src/modules/data/data.operations.js +3000 -0
  40. package/src/modules/data/data.relations.js +687 -0
  41. package/src/modules/data/data.routes.js +132 -38
  42. package/src/modules/data/data.scheduling.js +274 -0
  43. package/src/modules/data/data.validation.js +248 -0
  44. package/src/modules/data/index.js +29 -1
  45. package/src/modules/user.js +2 -1
  46. package/src/modules/workflow.js +3 -2
  47. package/src/services/stripe.js +3 -2
  48. package/swagger-en.yml +133 -0
  49. package/test/model.integration.test.js +377 -221
@@ -1,475 +1,33 @@
1
1
  import {Logger} from "../../gameObject.js";
2
- import {BSON, ObjectId} from "mongodb";
3
- import {promisify} from 'node:util';
4
- import crypto from "node:crypto";
5
- import {execFile} from 'node:child_process';
6
- import sanitizeHtml from 'sanitize-html';
7
- import * as tar from "tar";
8
- import process from "node:process";
9
- import {randomColor} from "randomcolor";
10
- import cronstrue from 'cronstrue/i18n.js';
11
2
  import {mkdir} from 'node:fs/promises';
12
3
  import {onInit as historyInit} from "./data.history.js";
13
- import {anonymizeText, getDefaultForType, getFieldValueHash, getUserId, isDemoUser, isLocalUser} from "../../data.js";
14
- import {
15
- allowedFields,
16
- dbName,
17
- install,
18
- maxAlertsPerUser,
19
- maxExportCount,
20
- maxFileSize,
21
- maxFilterDepth,
22
- maxModelNameLength,
23
- maxPasswordLength,
24
- maxPostData,
25
- maxRelationsPerData,
26
- maxRequestData,
27
- maxRichTextLength,
28
- maxStringLength,
29
- maxTotalDataPerUser,
30
- megabytes,
31
- optionsSanitizer,
32
- searchRequestTimeout,
33
- storageSafetyMargin
34
- } from "../../constants.js";
35
- import {
36
- createCollection,
37
- getCollection,
38
- getCollectionForUser,
39
- getUserCollectionName,
40
- isObjectId,
41
- modelsCollection,
42
- packsCollection
43
- } from "../mongodb.js";
44
- import {dbUrl, MongoClient, MongoDatabase} from "../../engine.js";
4
+ import {isDemoUser, isLocalUser} from "../../data.js";
5
+ import {install, maxRequestData, storageSafetyMargin} from "../../constants.js";
6
+ import {createCollection, getCollection} from "../mongodb.js";
45
7
  import path from "node:path";
46
- import {getObjectHash, getRandom, isGUID, isPlainObject, randomDate, sequential} from "../../core.js";
8
+ import {isGUID, sequential} from "../../core.js";
47
9
  import {Event} from "../../events.js";
48
10
  import fs from "node:fs";
49
11
  import schedule from "node-schedule";
50
12
  import {middleware} from "../../middlewares/middleware-mongodb.js";
51
13
  import i18n from "../../i18n.js";
52
- import {
53
- executeSafeJavascript,
54
- runScheduledJobWithDbLock,
55
- scheduleWorkflowTriggers, substituteVariables,
56
- triggerWorkflows
57
- } from "../workflow.js";
58
- import NodeCache from "node-cache";
59
- import AWS from 'aws-sdk';
60
14
  import checkDiskSpace from "check-disk-space";
61
- import {addFile, removeFile} from "../file.js";
62
- import {downloadFromS3, getUserS3Config, listS3Backups, uploadToS3} from "../bucket.js";
63
- import {calculateTotalUserStorageUsage, getSmtpConfig, hasPermission, middlewareAuthenticator} from "../user.js";
64
- import {getAllPacks} from "../../packs.js";
65
- import {Config} from "../../config.js";
15
+ import {removeFile} from "../file.js";
16
+ import {hasPermission} from "../user.js";
66
17
  import {profiles} from "../../../client/src/constants.js";
67
- import {registerRoutes, sendSseToUser} from "./data.routes.js";
68
- import {importJobs, modelsCache, mongoDBWhitelist, runCryptoWorkerTask, runImportExportWorker} from "./data.core.js";
69
- import readXlsxFile from "read-excel-file/node";
70
- import {sendEmail} from "../../email.js";
18
+ import {registerRoutes} from "./data.routes.js";
19
+ import {mongoDBWhitelist} from "./data.core.js";
20
+ import {onInit as relationsInit} from "./data.relations.js";
21
+ import {validateField, onInit as validationInit} from "./data.validation.js";
22
+ import {cancelAlerts, scheduleAlerts, onInit as scheduleInit} from "./data.scheduling.js";
23
+ import {deleteData, installPack, onInit as operationsInit} from "./data.operations.js";
24
+ import {jobDumpUserData, onInit as backupInit} from "./data.backup.js";
71
25
 
72
26
  let engine;
73
27
  let logger;
74
28
 
75
- const delay = ms => new Promise(res => setTimeout(res, ms));
76
-
77
- const getBackupDir = () => process.env.BACKUP_DIR || './backups'; // Répertoire de stockage des sauvegardes
78
- const execFileAsync = promisify(execFile);
79
-
80
- const IMPORT_CHUNK_SIZE = 100; // Nombre d'enregistrements à traiter par lot
81
- const IMPORT_CHUNK_DELAY_MS = 1000; // Délai en millisecondes entre le traitement des lots
82
-
83
- let depthFilter= 0;
84
-
85
29
  const DATA_STORAGE_PATH = path.resolve('./');
86
30
 
87
- // Création du cache avec des options configurables
88
- const relationCache = new NodeCache({
89
- stdTTL: 3600, // TTL par défaut de 1 heure (en secondes)
90
- checkperiod: 600, // Vérification des éléments expirés toutes les 10 minutes
91
- useClones: false // Pour des performances optimales avec des ObjectId
92
- });
93
-
94
- export const jobDumpUserData = async () => {
95
-
96
- try {
97
-
98
- const primalsDb = MongoClient.db("primals");
99
-
100
- let usersCollection = primalsDb.collection("users");
101
- const users = await usersCollection.find().toArray();
102
-
103
- users.forEach((user) =>
104
- {
105
- if( isDemoUser(user) && Config.Get("useDemoAccounts"))
106
- return;
107
- try {
108
- dumpUserData(user).catch(e => {
109
- Event.Trigger("OnUserDataDumped", "event", "system", engine);
110
- })
111
- } catch (ignored) {
112
-
113
- }
114
- });
115
-
116
- }catch (e) {
117
- console.error(e);
118
- }
119
- }
120
-
121
- export const dataTypes = {
122
- object: {
123
- validate: (value, field) => {
124
- return value === null || isPlainObject(value);
125
- }
126
- },
127
- model: {
128
- validate: (value, field) => {
129
- return value === null || typeof value === 'string' && value.length <= maxModelNameLength;
130
- }
131
- },
132
- cronSchedule: {
133
- validate: (value, field) => {
134
- if( value === null )
135
- return true;
136
- try {
137
- cronstrue.toString(value, { throwExceptionOnParseError: true });
138
- return true;
139
- } catch (e) {
140
- return false;
141
- }
142
- },
143
- filter: async (value, field)=>{
144
- if( value === null )
145
- return null;
146
- if (field.cronMask && field.default) {
147
- return applyCronMask(value, field.cronMask, field.default);
148
- }
149
- return value;
150
- }
151
- },
152
- modelField: {
153
- validate: (value, field) => {
154
- return value === null || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
155
- }
156
- },
157
- string: {
158
- validate: (value, field) => {
159
- const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
160
- return value === null || typeof value === 'string' && (!ml || value.length <= ml)
161
- },
162
- anonymize: anonymizeText
163
- },
164
- code: {
165
- validate: (value, field) => {
166
- return value === null || (field.language === 'json' && typeof(value) === 'object') || (typeof value === 'string' && (field.maxlength === undefined || field.maxlength <= 0 || value.length <= field.maxlength));
167
- },
168
- filter: async (value, field) => {
169
- if( field.language === 'json')
170
- {
171
- if( typeof(value) === 'object')
172
- return value;
173
- else if( typeof(value) === 'string') {
174
- try {
175
- return JSON.parse(value);
176
- } catch (e) {
177
- return null;
178
- }
179
- }else{
180
- return null;
181
- }
182
- }
183
- return value;
184
- },
185
- anonymize: (str) => anonymizeText(typeof(str) ==='object' ? JSON.stringify(str): str)
186
- },
187
- richtext: {
188
- validate: (value, field) => {
189
- const ml = Math.min(Math.max(field.maxlength,0), maxRichTextLength);
190
- return value === null || typeof value === 'string' && (!ml || value.length <= ml)
191
- },
192
- filter: async (value) =>{
193
- return sanitizeHtml(value, optionsSanitizer);
194
- },
195
- anonymize: anonymizeText
196
- },
197
- 'string_t': {
198
- validate: (value, field) => {
199
- if( value === null )
200
- return true;
201
- const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
202
- // La valeur peut être une chaîne de caractères...
203
- if (typeof value === 'string') {
204
- return !ml || value.length <= ml;
205
- }
206
- // ... ou un objet contenant une clé de type chaîne de caractères.
207
- if (typeof value === 'object' &&
208
- (typeof value.key === 'string' || value.key === null)) {
209
- return !ml || value.key.length <= ml;
210
- }
211
- return false; // Invalide si ce n'est aucun des deux.
212
- },
213
- filter: async (value, field) => {
214
- // Si la valeur est un objet avec une clé, on ne garde que la clé.
215
- if (typeof value === 'object' && value !== null &&
216
- (typeof value.key === 'string' || value.key === null)) {
217
- return value.key || '';
218
- }
219
- // Sinon, on garde la valeur telle quelle (qui devrait être une chaîne).
220
- return value;
221
- },
222
- anonymize: anonymizeText
223
- },
224
- password: {
225
- filter: async (value) => {
226
- if( value )
227
- return await runCryptoWorkerTask('hash', { data: value });
228
- return null;
229
- },
230
- validate: (value, field) => {
231
- const ml = Math.min(Math.max(field.maxlength,0), maxPasswordLength);
232
- return value === null || typeof value === 'string' && (!ml || value.length <= ml)
233
- },
234
- anonymize: anonymizeText
235
- },
236
- date: {
237
- validate: (value, field) => {
238
- if( value === null )
239
- return true;
240
- if( typeof(value) === 'string' && value.toLowerCase() === 'now')
241
- return true;
242
- if (typeof value !== 'string' ) return false;
243
- const dt = new Date(value);
244
-
245
- const dtMin = new Date(field.min || value);
246
- const dtMax = new Date(field.max || value);
247
- if( isNaN(dt) ){
248
- return false;
249
- }
250
- return ( dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
251
- },
252
- filter: async (value) => {
253
- if( typeof(value) === 'string' && value.toLowerCase() === "now"){
254
- return new Date().toISOString().split("T")[0];
255
- }
256
- if (value instanceof Date)
257
- return value.toISOString().split("T")[0];
258
- return value;
259
- },
260
- anonymize: (value, field) => {
261
- const min = new Date();
262
- const max = new Date();
263
- min.setFullYear(min.getFullYear()-1);
264
- max.setFullYear(max.getFullYear()+1);
265
- return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
266
- }
267
- },
268
- datetime: {
269
- validate: (value, field) => {
270
- if( typeof(value) === 'string' && value.toLowerCase() === 'now')
271
- return true;
272
- if (value instanceof Date || value === null)
273
- return true;
274
- if (typeof value !== 'string' || !value ) return false;
275
- const dt = new Date(value);
276
- const dtMin = new Date(field.min || value);
277
- const dtMax = new Date(field.max || value);
278
- if( isNaN(dt) ){
279
- return false;
280
- }
281
- return ( dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
282
- },
283
- filter: async (value) => {
284
- if( typeof(value) === 'string' && value.toLowerCase() === "now"){
285
- return new Date().toISOString();
286
- }
287
- if (value instanceof Date)
288
- return value.toISOString();
289
- return value;
290
- },
291
- anonymize: (value, field) => {
292
- const min = new Date();
293
- const max = new Date();
294
- min.setFullYear(min.getFullYear()-1);
295
- max.setFullYear(max.getFullYear()+1);
296
- return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
297
- }
298
- },
299
- phone: {
300
- prefixRegex: /^[+]?[(]?[0-9]{2,3}[)]?$/,
301
- validate: (value) => {
302
- if( value === null) return true;
303
- if (typeof value !== 'string') return false;
304
- if( !value ) return true;
305
- if( dataTypes.phone.prefixRegex.test(value) ) return true;
306
- const phoneRegex = /^[+]?[(]?[0-9]{2,3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/im;
307
- return phoneRegex.test(value);
308
- },
309
- filter: async (value) =>{
310
- if( dataTypes.phone.prefixRegex.test(value) ) return '';
311
- return value;
312
- },
313
- anonymize: anonymizeText
314
- },
315
- url: {
316
- validate: (value) => {
317
- if( value === null) return true;
318
- if (typeof value !== 'string') return false;
319
- if( !value.trim() ) return true;
320
- const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
321
- return expression.test(value);
322
- },
323
- anonymize: anonymizeText
324
- },
325
- number: {
326
- validate: (value, field) => {
327
- if( value === null) return true;
328
- const min = typeof(field.min) === 'number' ? field.min : null;
329
- const max = typeof(field.max) === 'number' ? field.max : null;
330
- if( min !== null && max !== null && max < min )
331
- return false;
332
- return typeof value === 'number' && !isNaN(value) && (min === null || value >= min) && (max === null || value <= max);
333
- },
334
- anonymize: (value, field) => {
335
- const min = typeof(field.min) === 'number' ? field.min : 0;
336
- const max = typeof(field.max) === 'number' ? field.max : Math.MAX_SAFE_INTEGER;
337
- return getRandom(min, max);
338
- }
339
- },
340
- boolean: {
341
- validate: (value) => value === null || typeof value === 'boolean',
342
- anonymize: () => {
343
- return !!getRandom(0, 1);
344
- }
345
- },
346
- array: {
347
- validate: (value, field) => {
348
- if( value === null) return true;
349
- if (!Array.isArray(value)) {
350
- return false;
351
- }
352
- if (field.minItems && value.length < field.minItems) {
353
- return false;
354
- }
355
- if (field.maxItems && value.length > field.maxItems) {
356
- return false;
357
- }
358
- return value.every(item => {
359
- if (!dataTypes[field.itemsType]) {
360
- throw new Error(`Invalid itemsType: ${field.itemsType}`);
361
- }
362
- return dataTypes[field.itemsType].validate(item, {field, type: field.itemsType });
363
- });
364
- },
365
- anonymize: () => []
366
- },
367
- enum: {
368
- validate: (value) => value === null || typeof(value) === 'string',
369
- anonymize: (value, field) => {
370
- return field.items[Math.floor(Math.random() * field.items.length)];
371
- }
372
- },
373
- // Types personnalisés
374
- email: {
375
- validate: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
376
- anonymize: anonymizeText
377
- },
378
- relation: {
379
- validate: (value, field) => {
380
- if( field.multiple ){
381
- return typeof(value) === 'object' || (Array.isArray(value) && value.length <= maxRelationsPerData && !value.some(v => {
382
- return !isObjectId(v);
383
- }));
384
- }
385
- return value === null || value === undefined || isObjectId(value) || typeof(value) === 'object';
386
- },
387
- anonymize: () => null
388
- },
389
- file: {
390
- validate: (value, field) => {
391
- // If no file is selected, it's considered valid (optional field)
392
- if (value === null || value === undefined) {
393
- return true;
394
- }
395
-
396
- // Check if the value is a string (filename or GUID)
397
- if (typeof value === 'string') {
398
- return true;
399
- }
400
-
401
- // Check if the value is a File object
402
- if (typeof (value) === 'object') {
403
- if (field.mimeTypes && !field.mimeTypes.includes(value.type)) {
404
- throw new Error(i18n.t('api.validate.invalidMimeType', { type: value.type, authorized: field.mimeTypes.join(', ') }));
405
- }
406
-
407
- // Check if the file size is within the limit
408
- if (value.size > (field.maxSize || maxFileSize )) {
409
- return false;
410
- }
411
-
412
- return true;
413
- }
414
-
415
- return false; // Invalid type
416
- },
417
- filter: async (value, field, reqFile) => {
418
- if (typeof value !== 'object') {
419
- return null;
420
- }
421
-
422
- return value;
423
- },
424
- anonymize: () => null
425
- },
426
- color: {
427
- validate: (value) => {
428
- // Vérification si la valeur est une chaîne de caractères et correspond à un format de couleur hexadécimal valide.
429
- return value === null ||typeof value === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
430
- },
431
- filter: async (value) => {
432
- // Nettoyage ou transformation de la valeur si nécessaire (par exemple, mise en majuscule des caractères hexadécimaux).
433
- return value ? value.toUpperCase() : null; // Retourne null si la valeur est null ou undefined
434
- },
435
- anonymize: () => {
436
- return randomColor({
437
- format: 'hex'
438
- });
439
- }
440
- },
441
- calculated: {
442
- validate: (value) => {
443
- return value !== undefined && value !== null && typeof value !== 'object' && !Array.isArray(value);
444
- },
445
- filter: async (value) => {
446
- return value;
447
- }
448
- },
449
- richtext_t: {
450
- validate: (value, field) => {
451
- // La valeur doit être un objet (ou null/undefined)
452
- if (value === null || typeof value === 'undefined') return true;
453
- if (typeof value !== 'object' || Array.isArray(value)) return false;
454
-
455
- // Chaque valeur dans l'objet doit être une chaîne de caractères (le HTML)
456
- return Object.values(value).every(html => typeof html === 'string');
457
- },
458
- filter: async (value) => {
459
- if (!value) return null;
460
- const sanitizedObject = {};
461
- for (const lang in value) {
462
- if (Object.prototype.hasOwnProperty.call(value, lang)) {
463
- // On réutilise le même sanitizer que pour le richtext simple
464
- sanitizedObject[lang] = sanitizeHtml(value[lang], optionsSanitizer);
465
- }
466
- }
467
- return sanitizedObject;
468
- },
469
- anonymize: () => ({}) // Anonymisation en objet vide
470
- }
471
- };
472
-
473
31
  export const getAPILang = (langs) => {
474
32
  if( typeof(langs) !== 'string')
475
33
  return 'en';
@@ -488,4164 +46,245 @@ export const getAPILang = (langs) => {
488
46
  }, []).sort(((p,r) => p.quality < r.quality ? 1 : -1))?.[0].lang.split(/[-_]/)?.[0];
489
47
  }
490
48
 
49
+ export async function onInit(defaultEngine) {
50
+ engine = defaultEngine;
51
+ logger = engine.getComponent(Logger);
491
52
 
492
- export async function validateModelStructure(modelStructure) {
493
- return await Event.Trigger("OnValidateModelStructure", "event", "system", modelStructure);
494
- }
495
-
496
- const validateField = (field) => {
497
-
498
- const allowedFieldTest = (fields)=>{
499
- // Check for unknown fields
500
- const unknownFields = Object.keys(field).filter(f => ![...allowedFields, ...fields].includes(f));
53
+ engine.use(middleware({ whitelist: mongoDBWhitelist }));
501
54
 
502
- if (unknownFields.length > 0) {
503
- throw new Error(i18n.t('api.validate.unknowField', `Propriété(s) non reconnue(s): '{{0}}' pour le champ '{{1}}'`, [unknownFields.join(', '), field.name]));
504
- }
55
+ let modelsCollection, datasCollection, filesCollection, packsCollection, magnetsCollection, historyCollection;
505
56
 
506
- const fieldInvalid = Object.keys(fields).find(f => JSON.stringify(field[f] || '').length > maxStringLength);
507
- if(fieldInvalid){
508
- throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, [fieldInvalid.name]));
57
+ if( install ) {
58
+ datasCollection = await createCollection("datas");
59
+ historyCollection = await createCollection("history");
60
+ filesCollection = await createCollection("files");
61
+ packsCollection = await createCollection("packs");
62
+ //data
63
+ const indexes = await datasCollection.indexes();
64
+ if (!indexes.find(i => i.name === 'genericPartialIndex')) {
65
+ await datasCollection.createIndex({"$**": 1}, {
66
+ name: 'genericPartialIndex',
67
+ partialFilterExpression: {
68
+ _model: 1,
69
+ _user: 1
70
+ }
71
+ });
509
72
  }
510
- }
511
-
512
- // Check for required fields
513
- if (!field.name || typeof field.name !== 'string') {
514
- throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["name"]));
515
- }
516
- if (!field.type || typeof field.type !== 'string') {
517
- throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["type"]));
518
- }
519
73
 
520
- // Check for specific field types
521
- switch (field.type) {
522
- case 'relation':
523
- allowedFieldTest(['relation', 'multiple', 'relationFilter']);
524
- if (!field.relation || typeof field.relation !== 'string' || field.relation.length > maxModelNameLength) {
525
- throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["relation"]));
526
- }
527
- if (field.multiple !== undefined && typeof field.multiple !== 'boolean') {
528
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["multiple"]));
529
- }
530
- if( field.relationFilter && typeof field.relationFilter !== 'object'){
531
- throw new Error(i18n.t('api.validate.fieldObject', "L'attribut '{{0}}' doit être un objet.", ["relationFilter"]));
532
- }
533
- break;
534
- case 'enum':
535
- {
536
- allowedFieldTest(['items']);
537
- if (!field.items || !Array.isArray(field.items) || field.items.length === 0) {
538
- throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["items"]));
539
- }
540
- let id = field.items.findIndex(item => typeof item !== 'string');
541
- if( id !== -1 ){
542
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["items["+id+"]"]));
543
- }
544
- break;
545
- }
546
- case 'number':
547
- allowedFieldTest(['min', 'max', 'step', 'unit', 'delay', 'gauge', 'percent']);
548
- if (field.min !== undefined && typeof field.min !== 'number') {
549
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["min"]));
550
- }
551
- if (field.max !== undefined && typeof field.max !== 'number') {
552
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["max"]));
553
- }
554
- if (field.max < field.min ){
555
- throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
556
- }
557
- if (field.step !== undefined && typeof field.step !== 'number') {
558
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["step"]));
559
- }
560
- if (field.unit !== undefined && typeof field.unit !== 'string') {
561
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
562
- }
563
- if (field.delay !== undefined && typeof field.delay !== 'boolean') {
564
- throw new Error(i18n.t('api.validate.fieldBoolean', "Le champ '{{0}}' doit être un booléen.", ["unit"]));
565
- }
566
- if (field.gauge !== undefined && typeof field.gauge !== 'boolean') {
567
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["gauge"]));
568
- }
569
- if (field.percent !== undefined && typeof field.percent !== 'boolean') {
570
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["percent"]));
571
- }
572
- break;
573
- case 'string':
574
- case 'string_t':
575
- case 'richtext':
576
- case 'richtext_t':
577
- case 'url':
578
- case 'email':
579
- case 'phone':
580
- case 'password':
581
- case 'code':
582
- if (field.type === 'code')
583
- allowedFieldTest(['maxlength', 'language', 'conditionBuilder', 'targetModel']);
584
- else if( ['string_t', 'string'].includes(field.type))
585
- allowedFieldTest(['maxlength', 'multiline']);
586
- else
587
- allowedFieldTest(['maxlength']);
588
- if (field.maxlength !== undefined && typeof field.maxlength !== 'number') {
589
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxlength"]));
590
- }
591
- break;
592
- case 'model':
593
- case 'modelField':
594
- allowedFieldTest([]);
595
- break;
596
- case 'object':
597
- allowedFieldTest([]);
598
- break;
599
- case 'boolean':
600
- allowedFieldTest([]);
601
- break;
602
- case 'date':
603
- case 'datetime':
604
- {
605
- allowedFieldTest(['min','max']);
606
- if (field.min !== undefined && typeof field.min !== 'string') {
607
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["min"]));
608
- }
609
- if (field.max !== undefined && typeof field.max !== 'string') {
610
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["max"]));
611
- }
612
- const dtMin = field.min ? new Date(field.min) : null;
613
- const dtMax = field.max ? new Date(field.max) : null;
614
- if( dtMin && dtMax && dtMin > dtMax){
615
- throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
74
+ if (! await datasCollection.indexExists("_hash") ) {
75
+ await datasCollection.createIndex({_hash: 1});
616
76
  }
617
- break;
618
- }
619
- case 'image':
620
- case 'file':
621
- {
622
- allowedFieldTest(['mimeTypes', 'maxSize']);
623
- if (field.mimeTypes !== undefined && !Array.isArray(field.mimeTypes)) {
624
- throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["mimeTypes"]));
77
+ if (! await datasCollection.indexExists("_model") ) {
78
+ await datasCollection.createIndex({_model: 1});
625
79
  }
626
- let id;
627
- if (field.mimeTypes !== undefined && (id = field.mimeTypes.findIndex(item => typeof item !== 'string')) !== -1) {
628
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["mimeTypes["+id+"]"]));
80
+ if (! await datasCollection.indexExists("_user") ) {
81
+ await datasCollection.createIndex({_user: 1});
629
82
  }
630
- if (field.maxSize !== undefined && typeof field.maxSize !== 'number') {
631
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxSize"]));
83
+ if (!indexes.find(i => i.name === 'modelUserIndex')) {
84
+ await datasCollection.createIndex({_model: 1, _user: 1}, { name: 'modelUserIndex'});
632
85
  }
633
- if (field.maxSize !== undefined && field.maxSize > maxFileSize) {
634
- throw new Error(i18n.t('api.validate.fileSize', `L'attribut 'maxSize' ne doit pas dépasser {{0}} octets.`, [maxFileSize]));
86
+
87
+ const jobsCollection = await createCollection("job_locks");
88
+ if (! await jobsCollection.indexExists("jobTTLIndex") ) {
89
+ await jobsCollection.createIndex({ "lockedUntil": 1 }, { name: "jobTTLIndex", expireAfterSeconds: 0 });
635
90
  }
636
- break;
637
- }
638
- case 'color':
639
- allowedFieldTest([]);
640
- return true;
641
- case 'cronSchedule':
642
- allowedFieldTest(['cronMask']);
643
- return true;
644
- case 'calculated':
645
- allowedFieldTest(['calculation']);
646
- return true;
647
- case 'array':
648
- if (!field.itemsType || typeof field.itemsType !== 'string') {
649
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["itemsType"]));
91
+ if (! await jobsCollection.indexExists("jobIdUnique") ) {
92
+ await jobsCollection.createIndex({ "jobId": 1 }, { name: "jobIdUnique", unique: true });
650
93
  }
651
- if (!dataTypes[field.itemsType]) {
652
- throw new Error(i18n.t('api.validate.invalidField', `Champ(s) non valide(s): '{{0}}'`, ["itemsType"]));
94
+
95
+ logger.info("Setting up indexes for 'files' collection...");
96
+ const filesIndexes = await filesCollection.indexes();
97
+
98
+ // Index composé pour les lookups fréquents par GUID et utilisateur
99
+ const compoundGuidUserIndexName = 'file_guid_user_idx';
100
+ if (!filesIndexes.find(i => i.name === compoundGuidUserIndexName)) {
101
+ await filesCollection.createIndex({ guid: 1, user: 1 }, { name: compoundGuidUserIndexName });
102
+ logger.info(`Created compound index '${compoundGuidUserIndexName}' on 'files' collection (guid: 1, user: 1).`);
103
+ } else {
104
+ logger.info(`Index '${compoundGuidUserIndexName}' already exists on 'files' collection.`);
653
105
  }
654
- if (field.minItems !== undefined && typeof field.minItems !== 'number') {
655
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["minItems"]));
106
+
107
+ const uniqueGuidIndexName = 'file_guid_unique_idx';
108
+ const existingGuidIndex = filesIndexes.find(i => i.name === uniqueGuidIndexName);
109
+ if (!existingGuidIndex) {
110
+ await filesCollection.createIndex({ guid: 1 }, { name: uniqueGuidIndexName, unique: true });
111
+ logger.info(`Created unique index '${uniqueGuidIndexName}' on 'guid' for 'files' collection.`);
112
+ } else if (existingGuidIndex.name !== uniqueGuidIndexName || !existingGuidIndex.unique) {
113
+ logger.warn(`An index on 'guid' exists for 'files' collection (name: ${existingGuidIndex.name}, unique: ${existingGuidIndex.unique}), but not matching desired spec (name: ${uniqueGuidIndexName}, unique: true). Manual review might be needed.`);
114
+ } else {
115
+ logger.info(`Unique index on 'guid' (name: '${existingGuidIndex.name}') already exists for 'files' collection.`);
656
116
  }
657
- if (field.maxItems !== undefined && typeof field.maxItems !== 'number') {
658
- throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxItems"]));
117
+
118
+ if (! await packsCollection.indexExists("_user") ) {
119
+ await packsCollection.createIndex({_user: 1});
659
120
  }
660
- break;
661
- default:
662
- throw new Error(i18n.t('api.validate.unknowType',`Le type '{{0}}' n'est pas reconnu.`, [field.type]));
663
- }
664
121
 
665
- // Check for optional fields
666
- if (field.required !== undefined && typeof field.required !== 'boolean') {
667
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["required"]));
668
- }
669
- if (field.hint !== undefined && typeof field.hint !== 'string') {
670
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["hint"]));
671
- }
672
- if (field.default !== undefined && field.default !== null && typeof field.default !== typeof getDefaultForType(field) && typeof field.default !== 'function') {
673
- throw new Error(i18n.t('api.validate.sameType', `L'attribut '{{0}}' doit être du même type que l'attribut '{{0}}' (${field.type}).`, ['default', 'type']));
674
- }
675
- if (field.validate !== undefined && typeof field.validate !== 'function') {
676
- throw new Error(i18n.t('api.validate.fieldFunction', "L'attribut '{{0}}' doit être une fonction.", ['validate']));
677
- }
678
- if (field.unique !== undefined && typeof field.unique !== 'boolean') {
679
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["unique"]));
680
- }
681
- if (field.placeholder !== undefined && typeof field.placeholder !== 'string') {
682
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["placeholder"]));
683
- }
684
- if (field.asMain !== undefined && typeof field.asMain !== 'boolean') {
685
- throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["asMain"]));
686
- }
687
- if (field.unit !== undefined && typeof field.unit !== 'string') {
688
- throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
689
- }
690
122
 
691
- return true;
692
- };
123
+ // Create the uploads directories
124
+ await mkdir(path.join("uploads", "tmp"),{ recursive: true});
693
125
 
694
- function convertDataTypes(dataArray, modelFields, sourceType = 'csv') {
695
- return dataArray.map(record => {
696
- const convertedRecord = { ...record };
697
- for (const field of modelFields) {
698
- if (convertedRecord.hasOwnProperty(field.name)) {
699
- let value = convertedRecord[field.name];
126
+ }else {
127
+ modelsCollection = getCollection("models");
128
+ datasCollection = getCollection("datas");
129
+ filesCollection = getCollection("files");
130
+ packsCollection = getCollection("packs");
131
+ historyCollection = getCollection("history");
132
+ }
700
133
 
701
- // Gérer les chaînes vides pour les champs non requis
702
- if (typeof value === 'string' && value === '' && !field.required) {
703
- convertedRecord[field.name] = getDefaultForType(field);
704
- continue;
705
- }
706
- // Si la valeur est null ou undefined, on la laisse telle quelle, la validation s'en chargera
707
- if (value === null || value === undefined) {
708
- continue;
709
- }
134
+ // Sub modules
135
+ backupInit(defaultEngine);
136
+ historyInit(defaultEngine);
137
+ validationInit(defaultEngine);
138
+ relationsInit(defaultEngine);
139
+ scheduleInit(defaultEngine);
140
+ operationsInit(defaultEngine);
710
141
 
711
- switch (field.type) {
712
- case 'number':
713
- if (typeof value !== 'number') { // Convertir si ce n'est pas déjà un nombre
714
- const num = parseFloat(value);
715
- if (!isNaN(num)) {
716
- convertedRecord[field.name] = num;
717
- } else {
718
- logger.warn(`Import: Impossible de parser le nombre pour le champ ${field.name}, valeur: ${value}. Utilisation de la valeur par défaut/null.`);
719
- convertedRecord[field.name] = getDefaultForType(field);
720
- }
721
- }
722
- break;
723
- case 'boolean':
724
- if (typeof value !== 'boolean') {
725
- convertedRecord[field.name] = ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase());
726
- }
727
- break;
728
- case 'date':
729
- case 'datetime':
730
- if (String(value).toLowerCase() === 'now') {
731
- convertedRecord[field.name] = 'now';
732
- } else {
733
- const parsedDate = new Date(value);
734
- if (!isNaN(parsedDate.getTime())) {
735
- convertedRecord[field.name] = field.type === 'date' ? parsedDate.toISOString().split("T")[0] : parsedDate.toISOString();
736
- } else if (value) { // Ne pas logger si la valeur était initialement vide/null
737
- logger.warn(`Import: Impossible de parser la date pour le champ ${field.name}, valeur: ${value}. La validation ulture s'en chargera.`);
738
- }
739
- }
740
- break;
741
- case 'array':
742
- if (['csv','excel'].includes(sourceType) && typeof value === 'string') {
743
- const arrayValues = value.split(/[,;]/).map(item => item.trim()).filter(item => item !== '');
744
- if (field.itemsType === 'number') {
745
- convertedRecord[field.name] = arrayValues.map(v => parseFloat(v)).filter(v => !isNaN(v));
746
- } else {
747
- convertedRecord[field.name] = arrayValues;
748
- }
749
- } else if (sourceType === 'json' && typeof value === 'string') {
750
- try {
751
- const parsedArray = JSON.parse(value);
752
- if (Array.isArray(parsedArray)) {
753
- convertedRecord[field.name] = parsedArray;
754
- // TODO: Potentiellement convertir les éléments de parsedArray ici si nécessaire
755
- } else {
756
- logger.warn(`Import: La chaîne JSON pour le champ tableau ${field.name} n'a pas été parsée en tableau. Valeur: ${value}.`);
757
- }
758
- } catch (e) {
759
- logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ tableau ${field.name}. Valeur: ${value}.`);
760
- }
761
- }
762
- // Si c'est déjà un tableau (cas JSON typique), on suppose que les types des éléments sont corrects
763
- // ou seront validés par pushDataUnsecure.
764
- else if (!Array.isArray(convertedRecord[field.name])) {
765
- convertedRecord[field.name] = getDefaultForType(field);
766
- }
767
- break;
768
- case 'object':
769
- if (['csv','excel'].includes(sourceType)) {
770
- try {
771
- convertedRecord[field.name] = JSON.parse(value);
772
- } catch (e) {
773
- logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ objet ${field.name}. Valeur: ${value}.`);
774
- }
775
- }
776
- break;
777
- case 'code':
778
- if (['csv','excel'].includes(sourceType) && typeof value === 'string') {
779
- try {
780
- convertedRecord[field.name] = JSON.parse(value);
781
- } catch (e) {
782
- logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ code (json) ${field.name}. Valeur: ${value}.`);
783
- }
784
- }
785
- break;
786
- }
787
- }
788
- }
789
- return convertedRecord;
790
- });
791
- }
792
142
 
793
- export const cancelAlerts = async (user) => {
143
+ await registerRoutes(engine);
144
+ logger = engine.getComponent(Logger);
145
+
146
+ // set backup scheduler
147
+ schedule.scheduleJob("0 2 * * *", jobDumpUserData);
148
+ //await jobDumpUserData();
794
149
 
795
- const datasCollection = getCollection('datas'); // Alerts are in the global collection
796
150
 
797
- // 1. Fetch the latest state of the alert
798
- const alertDocs = await datasCollection.find({ _user: user.username, _model: 'alert' }).toArray();
799
- alertDocs.forEach(doc => {
800
- const jobId = `alert_${doc._id}`;
801
- schedule.scheduledJobs[jobId]?.cancel();
151
+ schedule.scheduleJob("0 0 * * *", async () => {
152
+ const dt = new Date();
153
+ dt.setTime(dt.getTime()-1000*3600*24*14);
154
+ await deleteData("request", {"$lt": ["$timestamp",dt.toISOString()]}, null, false);
802
155
  });
803
- }
156
+ await scheduleAlerts();
804
157
 
805
- /**
806
- * Executes a stateful alert job. It checks if a notification for an alert
807
- * has already been sent and only sends one if the condition is met and no
808
- * recent notification exists. The state is tracked via the 'lastNotifiedAt'
809
- * field in the alert document.
810
- * @param {string|ObjectId} alertId - The ID of the alert to process.
811
- */
812
- async function runStatefulAlertJob(alertId) {
813
- const jobId = `alert_${alertId}`;
814
- logger.info(`[Scheduled Job] Cron triggered for stateful alert job ${jobId}.`);
158
+ // Triggers
815
159
 
816
- try {
817
- const datasCollection = getCollection('datas'); // Alerts are in the global collection
160
+ Event.Listen("OnValidateModelStructure", async (modelStructure) =>{
818
161
 
819
- // 1. Fetch the latest state of the alert
820
- const alertDoc = await datasCollection.findOne({ _id: new ObjectId(alertId) });
162
+ const objectKeys = Object.keys(modelStructure);
821
163
 
822
- // Safety checks
823
- if (!alertDoc) {
824
- logger.warn(`[Scheduled Job] Alert ${alertId} not found. Cancelling job.`);
825
- schedule.scheduledJobs[jobId]?.cancel();
826
- return;
164
+ if( objectKeys.find(o => !["name", "_user", "icon", "history", "locked", "_id", "description", "maxRequestData", "fields"].includes(o)) ){
165
+ throw new Error(i18n.t('api.model.invalidStructure'));
827
166
  }
828
167
 
829
- if (!alertDoc.isActive || !alertDoc.frequency) {
830
- logger.info(`[Scheduled Job] Alert ${alertId} is no longer active or has no frequency. Cancelling job.`);
831
- schedule.scheduledJobs[jobId]?.cancel();
832
- return;
168
+ // Vérification du type de name
169
+ if (typeof modelStructure.name !== 'string' || !modelStructure.name) {
170
+ throw new Error(i18n.t("api.validate.requiredFieldString", ["name"]));
833
171
  }
834
172
 
835
- // 2. Check if a notification has already been sent for this state
836
- if (alertDoc.lastNotifiedAt) {
837
- logger.debug(`[Scheduled Job] Notification for alert ${alertId} has already been sent. Skipping evaluation.`);
838
- return;
173
+ // Vérification du type de description
174
+ if (typeof modelStructure.description !== 'string') {
175
+ throw new Error(i18n.t("api.validate.fieldString", ["description"]));
839
176
  }
840
177
 
841
- // 3. Evaluate the trigger condition
842
- const apiFilter = (alertDoc.triggerCondition);
843
- const { count } = await searchData({
844
- model: alertDoc.targetModel,
845
- filter: apiFilter,
846
- limit: 1
847
- }, { username: alertDoc._user });
178
+ // Vérification de la présence et du type du tableau fields
179
+ if (!Array.isArray(modelStructure.fields)) {
180
+ throw new Error(i18n.t('api.validate.fieldArray', ["fields"]));
181
+ }
848
182
 
849
- // 4. If condition is met, send notification and update state
850
- if (count > 0) {
851
- logger.info(`[Scheduled Job] Condition met for alert ${alertDoc.name} (ID: ${alertId}). Sending notification and updating state.`);
183
+ // Vérification de chaque champ dans le tableau fields
184
+ for (const field of modelStructure.fields) {
185
+ validateField(field);
186
+ }
852
187
 
853
- let emailSent = false;
854
- try {
855
- const user = await engine.userProvider.findUserByUsername(alertDoc._user);
856
- if (user && user.email) {
857
- const smtpConfig = await getSmtpConfig(user);
858
- if (alertDoc.sendEmail && smtpConfig) {
859
- const userLang = user.lang || 'en';
860
- let emailContent, msg;
861
- if (alertDoc.message){
862
- if (alertDoc.message[userLang])
863
- msg= alertDoc.message[userLang];
864
- else
865
- msg = alertDoc.message[Object.keys(alertDoc.message)[0]];
866
- emailContent = await substituteVariables(msg, { count, alert: alertDoc });
867
- } else {
868
- // Sinon, utiliser le message par défaut
869
- emailContent = i18n.t('alert.email.content', `L'alerte '${alertDoc.name}' s'est déclenchée. ${count} élément(s) correspondent à votre condition.`, { name: alertDoc.name, count: count });
188
+ if (modelStructure.constraints) {
189
+ if (!Array.isArray(modelStructure.constraints)) {
190
+ throw new Error('Model "constraints" property must be an array.');
191
+ }
192
+ const fieldNames = new Set(modelStructure.fields.map(f => f.name));
193
+ for (const constraint of modelStructure.constraints) {
194
+ if (constraint.type === 'unique') {
195
+ if (!constraint.name || !Array.isArray(constraint.keys) || constraint.keys.length === 0) {
196
+ throw new Error('Unique constraint must have a "name" and a non-empty "keys" array.');
197
+ }
198
+ for (const key of constraint.keys) {
199
+ if (!fieldNames.has(key)) {
200
+ throw new Error(`Constraint key "${key}" in constraint "${constraint.name}" does not exist as a field in the model.`);
870
201
  }
871
-
872
- await sendEmail(
873
- user.email,
874
- {
875
- title: i18n.t('alert.email.title', `Alerte: ${alertDoc.name}`),
876
- content: emailContent
877
- },
878
- smtpConfig,
879
- userLang
880
- );
881
- emailSent = true;
882
- logger.info(`[Scheduled Job] Email notification sent for alert ${alertId} to ${user.email}.`);
883
- } else if (alertDoc.sendEmail) {
884
- logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. SMTP config is missing or incomplete for user ${user.username}.`);
885
202
  }
886
- } else {
887
- logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. User ${alertDoc._user} not found or has no email address.`);
888
203
  }
889
- } catch (emailError) {
890
- logger.error(`[Scheduled Job] Failed to send email for alert ${alertId}:`, emailError);
891
204
  }
892
-
893
- // Send notification
894
- const alertPayload = {
895
- type: 'cron_alert',
896
- triggerId: alertDoc._id.toString(),
897
- triggerName: alertDoc.name,
898
- timestamp: new Date().toISOString(),
899
- message: `Alerte '${alertDoc.name}': ${count} élément(s) correspondent à votre condition.`,
900
- emailSent
901
- };
902
- sendSseToUser(alertDoc._user, alertPayload);
903
-
904
- // Update state in DB to prevent re-notification
905
- await datasCollection.updateOne(
906
- { _id: new ObjectId(alertId) },
907
- { $set: { lastNotifiedAt: new Date() } }
908
- );
909
- } else {
910
- logger.debug(`[Scheduled Job] Condition not met for alert ${alertId}. No notification sent.`);
911
205
  }
912
206
 
913
- } catch (error) {
914
- logger.error(`[Scheduled Job] Error processing stateful alert job ${jobId}:`, error);
915
- }
916
- }
207
+ return true; // La structure du modèle est valide
208
+ }, "event", "system");
917
209
 
210
+ }
918
211
 
919
212
 
920
- export async function scheduleAlerts() {
921
- logger.info('[scheduleAlerts] Starting scheduling of oldest active alerts per user...');
213
+ /**
214
+ * Vérifie si l'ajout de nouvelles données dépasserait la capacité de stockage globale du serveur.
215
+ * @param {number} incomingDataSize - La taille des données entrantes en octets.
216
+ * @returns {Promise<{isSufficient: boolean, free?: number, total?: number, error?: string}>}
217
+ */
218
+ export async function checkServerCapacity(incomingDataSize = 0) {
922
219
  try {
923
- const datasCollection = getCollection('datas');
924
-
925
- // --- NOUVELLE LOGIQUE AVEC AGRÉGATION ---
926
- const aggregationPipeline = [
927
- // 1. Match: Ne sélectionner que les alertes actives avec une fréquence définie.
928
- {
929
- $match: {
930
- _model: 'alert',
931
- isActive: true,
932
- frequency: { $exists: true, $ne: "" }
933
- }
934
- },
935
- // 2. Sort: Trier les alertes par utilisateur, puis par date de création (les plus anciennes en premier).
936
- // L'ObjectId contient un timestamp, donc trier par _id est équivalent à trier par date de création.
937
- {
938
- $sort: {
939
- _user: 1, // Grouper par utilisateur
940
- _id: 1 // Trier par date de création (ascendant)
941
- }
942
- },
943
- // 3. Group: Regrouper toutes les alertes par utilisateur dans un tableau.
944
- {
945
- $group: {
946
- _id: "$_user", // La clé de groupement est le nom de l'utilisateur
947
- alerts: { $push: "$$ROOT" } // $$ROOT pousse le document entier dans le tableau 'alerts'
948
- }
949
- },
950
- // 4. Project (Slice): Pour chaque utilisateur, ne garder que les X premières alertes du tableau trié.
951
- {
952
- $project: {
953
- oldestAlerts: { $slice: ["$alerts", maxAlertsPerUser] }
954
- }
955
- },
956
- // 5. Unwind: Déconstruire le tableau 'oldestAlerts' pour obtenir un flux de documents, un par alerte.
957
- {
958
- $unwind: "$oldestAlerts"
959
- },
960
- // 6. ReplaceRoot: Remplacer la structure du document par le contenu de l'alerte elle-même.
961
- {
962
- $replaceRoot: { newRoot: "$oldestAlerts" }
963
- }
964
- ];
965
-
966
- const alertsToSchedule = await datasCollection.aggregate(aggregationPipeline).toArray();
967
- // --- FIN DE LA NOUVELLE LOGIQUE ---
220
+ const diskSpace = await checkDiskSpace(DATA_STORAGE_PATH);
221
+ const { free, size } = diskSpace;
968
222
 
969
- logger.info(`[scheduleAlerts] Found ${alertsToSchedule.length} oldest active alerts across all users to schedule.`);
223
+ // Limite maximale d'utilisation du disque (ex: 90% de la taille totale)
224
+ const maxAllowedUsage = size * storageSafetyMargin;
225
+ const currentUsage = size - free;
226
+ const projectedUsage = currentUsage + incomingDataSize;
970
227
 
971
- for (const alertDoc of alertsToSchedule) {
972
- const jobId = `alert_${alertDoc._id}`;
973
- try {
974
- // Annuler une tâche existante pour la même alerte si elle existe (logique de sécurité)
975
- if (schedule.scheduledJobs[jobId]) {
976
- schedule.scheduledJobs[jobId].cancel();
977
- }
978
- // Planifier la tâche avec la nouvelle logique stateful
979
- schedule.scheduleJob(jobId, alertDoc.frequency, () => runStatefulAlertJob(alertDoc._id));
980
- } catch (scheduleError) {
981
- logger.error(`[scheduleAlerts] Failed to schedule job ${jobId} for alert ${alertDoc._id}. Error: ${scheduleError.message}`);
982
- }
228
+ if (projectedUsage > maxAllowedUsage) {
229
+ logger.warn(`[checkServerCapacity] Alert: Projected usage (${projectedUsage} bytes) would exceed the server's safety limit (${maxAllowedUsage} bytes).`);
230
+ return {
231
+ isSufficient: false,
232
+ free,
233
+ total: size
234
+ };
983
235
  }
984
- logger.info('[scheduleAlerts] Finished scheduling alerts.');
985
- } catch (error) {
986
- logger.error('[scheduleAlerts] A critical error occurred during alert scheduling:', error);
987
- }
988
- }
989
-
990
- /**
991
- * Applique un masque et des valeurs par défaut à une expression cron.
992
- * @param {string} cronString - L'expression cron d'entrbooléens. `false` pour désactiver et appliquer la valeur par défaut.
993
- * @param {string[]} defaults - Un tableau de 5 chaînes de caractères pour les valeurs par défaut.
994
- * @returns {string} - L'expression cron modifiée.
995
- */
996
- function applyCronMask(cronString, mask, defaults) {
997
- if (typeof cronString !== 'string' || cronString.trim() === '' || !mask || !defaults) {
998
- return cronString;
999
- }
1000
- const parts = cronString.split(' ');
1001
- if (parts.length < 5) {
1002
- return cronString; // Laisse la validation standard gérer les formats incorrects
236
+ return { isSufficient: true, free, total: size };
237
+ } catch (err) {
238
+ logger.error(`[checkServerCapacity] CRITICAL: Failed to check disk space: ${err.message}. Allowing write operation as a failsafe. Please investigate disk permissions or configuration.`);
239
+ // Failsafe: On autorise l'ure si la vérification échoue, mais on logue une erreur critique.
240
+ return { isSufficient: true, error: 'Could not verify disk space.' };
1003
241
  }
1004
-
1005
- const newParts = parts.slice(0, 5).map((part, index) => {
1006
- // Si le masque à cet index est `false`, on force la valeur par défaut.
1007
- if (mask[index] === false) {
1008
- return defaults[index];
1009
- }
1010
- // Sinon, on garde la valeur de l'utilisateur.
1011
- return part;
1012
- });
1013
-
1014
- return newParts.join(' ');
1015
242
  }
1016
243
 
1017
244
 
1018
- export const editModel = async (user, id, data) => {
1019
-
1020
- if( !(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_EDIT_MODEL"], user)){
1021
- return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
1022
- }
1023
-
1024
- const dataModel = data;
1025
- try {
1026
- const collection = await getCollectionForUser(user);
1027
- await validateModelStructure(dataModel);
1028
-
1029
- const el = await modelsCollection.findOne({ $and: [
1030
- {_user: {$exists: true}},
1031
- { _id: new ObjectId(id) },
1032
- {$and: [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}]
1033
- }
1034
- ]});
1035
-
1036
- if( !el ){
1037
- return ({success: false, statusCode: 404, error: i18n.t("api.model.notFound", { model: dataModel.name })});
1038
- }
1039
-
1040
- // renommage du modèle
1041
- if (typeof (data.name)==='string'&&el.name !== data.name && data.name ){
1042
- await collection.updateMany({ _model: el.name }, { $set: { _model: data.name }});
1043
- await modelsCollection.updateMany({ 'fields' : {
1044
- '$elemMatch' : { relation: el.name }
1045
- }}, {
1046
- $set : {
1047
- 'fields.$.relation' : data.name
1048
- }
1049
- })
1050
- }
1051
-
1052
- const coll = await getCollectionForUser(user);
1053
- // Update indexes
1054
- // Update indexes
1055
- if (await engine.userProvider.hasFeature(user, 'indexes')) {
1056
- let indexes = [];
1057
- try {
1058
- // On essaie de récupérer les index existants
1059
- indexes = await coll.indexes();
1060
- } catch (e) {
1061
- // Si la collection n'existe pas, c'est normal.
1062
- // createIndex la créera. Il n'y a juste pas d'index à supprimer.
1063
- if (e.codeName !== 'NamespaceNotFound') {
1064
- throw e; // On relance les autres erreurs
1065
- }
1066
- }
1067
-
1068
- // Le reste de votre logique de gestion d'index peut maintenant s'exécuter en toute sécurité
1069
- for (const field of data.fields) {
1070
- const elField = el.fields.find(f => f.name === field.name);
1071
- if (!elField) continue;
1072
-
1073
- const index = indexes.find(i => i.key[field.name] === 1 &&
1074
- i.partialFilterExpression?._model === el.name &&
1075
- i.partialFilterExpression?._user === user.username);
1076
-
1077
- if (elField.index !== field.index && !field.index) {
1078
- if (index) {
1079
- await coll.dropIndex(index.name);
1080
- }
1081
- } else if (elField.index !== field.index && field.index) {
1082
- if (!index) {
1083
- await coll.createIndex({ [field.name]: 1 }, {
1084
- partialFilterExpression: {
1085
- _model: data.name,
1086
- _user: user.username
1087
- }
1088
- });
1089
- }
1090
- }
1091
- }
1092
- }
1093
- // suppression des données à la suppression des champs
1094
- const unset = {};
1095
- el.fields.filter(f=> !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
1096
- unset[f] = 1;
1097
- });
1098
- await collection.updateMany({ _model: el.name }, { $unset: unset });
1099
-
1100
- // sauvegarde du modele
1101
- const set = {...data};
1102
- delete set['_id'];
1103
-
1104
- const oid = new ObjectId(id);
1105
- await modelsCollection.updateOne({_id: oid}, {$set: set});
1106
-
1107
- modelsCache.del(user.username+'@@'+el.name);
245
+ export const getResource = async (guid, user) => {
246
+ if (!guid) throw new Error("Le GUID du fichier est requis.");
247
+ if (!isGUID(guid)) throw new Error("Le GUID du fichier n'est pas valide.");
1108
248
 
1109
- const model = await modelsCollection.findOne({_id: oid });
1110
- triggerWorkflows(model, user, 'ModelEdited').catch(workflowError => {
1111
- logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
1112
- });
249
+ const collection = getCollection("files");
250
+ const file = await collection.findOne({ guid });
1113
251
 
1114
- const newModel = await modelsCollection.findOne({_id : oid});
1115
- const res = ({ success: true, data: newModel });
1116
- const plugin = await Event.Trigger("OnModelEdited", "event", "system", engine, newModel);
1117
- await Event.Trigger("OnModelEdited", "event", "user", plugin?.data || newModel);
1118
- return plugin || res
1119
- } catch (e) {
1120
- logger.error(e);
1121
- return ({ success: false, error: e.message, statusCode: 500 });
252
+ if (!file) {
253
+ throw new Error("Fichier non trouvé.");
1122
254
  }
1123
- };
1124
-
1125
-
1126
- export async function middlewareEndpointAuthenticator(req, res, next) {
1127
- const { path } = req.params;
1128
- const method = req.method.toUpperCase();
1129
- const user = await engine.userProvider.findUserByUsername(req.query._user || req.params.user || req.me.username);
1130
- const datasCollection = await getCollectionForUser(user);
1131
255
 
1132
- try {
1133
- const endpointDef = await datasCollection.findOne({
1134
- _model: 'endpoint',
1135
- path: path,
1136
- method: method,
1137
- isActive: true
1138
- });
1139
-
1140
- if (!endpointDef) {
1141
- return res.status(404).json({ success: false, message: 'Endpoint not found.' });
1142
- }
1143
-
1144
- // Attacher la définition à la requête pour que le handler suivant puisse l'utiliser
1145
- req.endpointDef = endpointDef;
1146
-
1147
- // Si l'endpoint n'est PAS public, on exécute le vrai middleware d'authentification
1148
- if (!endpointDef.isPublic) {
1149
- // On "chaîne" vers le middleware authenticator standard.
1150
- // Il se chargera de vérifier le token et de renvoyer une 401 si nécessaire.
1151
- return middlewareAuthenticator(req, res, next);
256
+ // La vérification des permissions reste la même...
257
+ if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_READ_FILE", `API_READ_FILE_privateFile_${guid}`], user)) {
258
+ if (file.user !== (user._user || user.username)) {
259
+ throw new Error("Vous n'êtes pas autorisé à accéder à ce fichier.");
1152
260
  }
1153
-
1154
- // Si l'endpoint EST public, on passe simplement à la suite.
1155
- next();
1156
-
1157
- } catch (error) {
1158
- logger.error(`[EndpointAuth] Critical error: ${error.message}`, error.stack);
1159
- res.status(500).json({ success: false, message: 'Internal server error during endpoint authentication.' });
1160
261
  }
1161
- }
1162
-
1163
- export async function handleCustomEndpointRequest(req, res) {
1164
- const endpointDef = req.endpointDef;
1165
-
1166
- try {
1167
- let executionUser = null;
1168
262
 
1169
- // 1. Déterminer le contexte utilisateur pour l'exécution
1170
- if (endpointDef.isPublic) {
1171
- // Pour les endpoints publics, on exécute le script en tant que propriétaire de l'endpoint.
1172
- if (!endpointDef._user) {
1173
- logger.error(`[Endpoint] Misconfiguration: Public endpoint '${endpointDef.name}' (ID: ${endpointDef._id}) has no owner.`);
1174
- return res.status(500).json({ success: false, message: 'Endpoint misconfigured: owner missing.' });
1175
- }
1176
- executionUser = await engine.userProvider.findUserByUsername(endpointDef._user);
1177
- if (!executionUser) {
1178
- logger.error(`[Endpoint] Execution failed: Owner '${endpointDef._user}' for public endpoint '${endpointDef.name}' not found.`);
1179
- return res.status(500).json({ success: false, message: 'Endpoint owner not found.' });
1180
- }
1181
- logger.info(`[Endpoint] Public endpoint '${endpointDef.name}' running as owner '${executionUser.username}'.`);
1182
- } else {
1183
- // Pour les endpoints privés, l'utilisateur a déjà été authentifié par le middleware.
1184
- // req.me est garanti d'exister ici.
1185
- executionUser = req.me;
1186
- logger.info(`[Endpoint] Private endpoint '${endpointDef.name}' running as authenticated user '${executionUser.username}'.`);
1187
- }
1188
-
1189
- // 2. Préparer le contexte pour le script
1190
- const contextData = {
1191
- request: {
1192
- // MODIFICATION: Utiliser req.body si disponible (pour les requêtes JSON comme les webhooks Stripe),
1193
- // sinon, utiliser req.fields (pour les données de formulaire).
1194
- body: (req.body && Object.keys(req.body).length > 0) ? req.body : (req.fields || {}),
1195
- query: req.query,
1196
- params: req.params,
1197
- headers: req.headers
1198
- }
263
+ // On retourne des informations différentes selon le type de stockage
264
+ if (file.storage === 's3') {
265
+ return {
266
+ success: true,
267
+ storage: 's3',
268
+ s3Key: file.filename, // 'filename' contient la clé S3
269
+ mimeType: file.mimeType
270
+ // Idlement, on aurait aussi le nom de fichier original ici
1199
271
  };
1200
-
1201
- // 3. Exécuter le code de l'endpoint
1202
- const result = await executeSafeJavascript(
1203
- { script: endpointDef.code },
1204
- contextData,
1205
- executionUser // Use the determined user for execution
1206
- );
1207
-
1208
- // 4. Envoyer la réponse
1209
- if (result.success) {
1210
- res.status(200).json(result.data);
1211
- } else {
1212
- logger.error(`[Endpoint] Execution failed for '${endpointDef.name}'. Error: ${result.message}`);
1213
- const responseError = {
1214
- success: false,
1215
- message: 'Endpoint script execution failed.',
1216
- details: result.message,
1217
- logs: result.logs
1218
- };
1219
- res.status(500).json(responseError);
1220
- }
1221
-
1222
- } catch (error) {
1223
- logger.error(`[Endpoint] Critical error handling request for path '${endpointDef.path}': ${error.message}`, error.stack);
1224
- res.status(500).json({ success: false, message: 'An internal server error occurred.' });
1225
- }
1226
- }
1227
-
1228
- export async function onInit(defaultEngine) {
1229
- engine = defaultEngine;
1230
- logger = engine.getComponent(Logger);
1231
-
1232
- engine.use(middleware({ whitelist: mongoDBWhitelist }));
1233
-
1234
- let modelsCollection, datasCollection, filesCollection, packsCollection, magnetsCollection, historyCollection;
1235
-
1236
- if( install ) {
1237
- datasCollection = await createCollection("datas");
1238
- historyCollection = await createCollection("history");
1239
- filesCollection = await createCollection("files");
1240
- packsCollection = await createCollection("packs");
1241
- //data
1242
- const indexes = await datasCollection.indexes();
1243
- if (!indexes.find(i => i.name === 'genericPartialIndex')) {
1244
- await datasCollection.createIndex({"$**": 1}, {
1245
- name: 'genericPartialIndex',
1246
- partialFilterExpression: {
1247
- _model: 1,
1248
- _user: 1
1249
- }
1250
- });
1251
- }
1252
-
1253
- if (! await datasCollection.indexExists("_hash") ) {
1254
- await datasCollection.createIndex({_hash: 1});
1255
- }
1256
- if (! await datasCollection.indexExists("_model") ) {
1257
- await datasCollection.createIndex({_model: 1});
1258
- }
1259
- if (! await datasCollection.indexExists("_user") ) {
1260
- await datasCollection.createIndex({_user: 1});
1261
- }
1262
- if (!indexes.find(i => i.name === 'modelUserIndex')) {
1263
- await datasCollection.createIndex({_model: 1, _user: 1}, { name: 'modelUserIndex'});
1264
- }
1265
-
1266
- const jobsCollection = await createCollection("job_locks");
1267
- if (! await jobsCollection.indexExists("jobTTLIndex") ) {
1268
- await jobsCollection.createIndex({ "lockedUntil": 1 }, { name: "jobTTLIndex", expireAfterSeconds: 0 });
1269
- }
1270
- if (! await jobsCollection.indexExists("jobIdUnique") ) {
1271
- await jobsCollection.createIndex({ "jobId": 1 }, { name: "jobIdUnique", unique: true });
1272
- }
1273
-
1274
- logger.info("Setting up indexes for 'files' collection...");
1275
- const filesIndexes = await filesCollection.indexes();
1276
-
1277
- // Index composé pour les lookups fréquents par GUID et utilisateur
1278
- const compoundGuidUserIndexName = 'file_guid_user_idx';
1279
- if (!filesIndexes.find(i => i.name === compoundGuidUserIndexName)) {
1280
- await filesCollection.createIndex({ guid: 1, user: 1 }, { name: compoundGuidUserIndexName });
1281
- logger.info(`Created compound index '${compoundGuidUserIndexName}' on 'files' collection (guid: 1, user: 1).`);
1282
- } else {
1283
- logger.info(`Index '${compoundGuidUserIndexName}' already exists on 'files' collection.`);
1284
- }
1285
-
1286
- const uniqueGuidIndexName = 'file_guid_unique_idx';
1287
- const existingGuidIndex = filesIndexes.find(i => i.name === uniqueGuidIndexName);
1288
- if (!existingGuidIndex) {
1289
- await filesCollection.createIndex({ guid: 1 }, { name: uniqueGuidIndexName, unique: true });
1290
- logger.info(`Created unique index '${uniqueGuidIndexName}' on 'guid' for 'files' collection.`);
1291
- } else if (existingGuidIndex.name !== uniqueGuidIndexName || !existingGuidIndex.unique) {
1292
- logger.warn(`An index on 'guid' exists for 'files' collection (name: ${existingGuidIndex.name}, unique: ${existingGuidIndex.unique}), but not matching desired spec (name: ${uniqueGuidIndexName}, unique: true). Manual review might be needed.`);
1293
- } else {
1294
- logger.info(`Unique index on 'guid' (name: '${existingGuidIndex.name}') already exists for 'files' collection.`);
1295
- }
1296
-
1297
- if (! await packsCollection.indexExists("_user") ) {
1298
- await packsCollection.createIndex({_user: 1});
272
+ } else { // Par défaut, on considère le stockage local
273
+ // On utilise le chemin stocké en base de données
274
+ const filepath = file.path;
275
+ if (!filepath || !fs.existsSync(filepath)) {
276
+ throw new Error("Fichier non trouvé sur le serveur.");
1299
277
  }
1300
-
1301
-
1302
- // Create the uploads directories
1303
- await mkdir(path.join("uploads", "tmp"),{ recursive: true});
1304
-
1305
- }else {
1306
- modelsCollection = getCollection("models");
1307
- datasCollection = getCollection("datas");
1308
- filesCollection = getCollection("files");
1309
- packsCollection = getCollection("packs");
1310
- historyCollection = getCollection("history");
278
+ return {
279
+ success: true,
280
+ storage: 'local',
281
+ filepath: filepath,
282
+ filename: file.filename,
283
+ mimeType: file.mimeType
284
+ };
1311
285
  }
1312
- await registerRoutes(engine);
1313
- logger = engine.getComponent(Logger);
1314
-
1315
- // set backup scheduler
1316
- schedule.scheduleJob("0 2 * * *", jobDumpUserData);
1317
- //await jobDumpUserData();
1318
-
1319
-
1320
- schedule.scheduleJob("0 0 * * *", async () => {
1321
- const dt = new Date();
1322
- dt.setTime(dt.getTime()-1000*3600*24*14);
1323
- await deleteData("request", {"$lt": ["$timestamp",dt.toISOString()]}, null, false);
1324
- });
1325
- await scheduleAlerts();
1326
-
1327
- // Triggers
1328
-
1329
- Event.Listen("OnValidateModelStructure", async (modelStructure) =>{
1330
-
1331
- const objectKeys = Object.keys(modelStructure);
1332
-
1333
- if( objectKeys.find(o => !["name", "_user", "icon", "history", "locked", "_id", "description", "maxRequestData", "fields"].includes(o)) ){
1334
- throw new Error(i18n.t('api.model.invalidStructure'));
1335
- }
1336
-
1337
- // Vérification du type de name
1338
- if (typeof modelStructure.name !== 'string' || !modelStructure.name) {
1339
- throw new Error(i18n.t("api.validate.requiredFieldString", ["name"]));
1340
- }
1341
-
1342
- // Vérification du type de description
1343
- if (typeof modelStructure.description !== 'string') {
1344
- throw new Error(i18n.t("api.validate.fieldString", ["description"]));
1345
- }
1346
-
1347
- // Vérification de la présence et du type du tableau fields
1348
- if (!Array.isArray(modelStructure.fields)) {
1349
- throw new Error(i18n.t('api.validate.fieldArray', ["fields"]));
1350
- }
1351
-
1352
- // Vérification de chaque champ dans le tableau fields
1353
- for (const field of modelStructure.fields) {
1354
- validateField(field);
1355
- }
286
+ };
1356
287
 
1357
- if (modelStructure.constraints) {
1358
- if (!Array.isArray(modelStructure.constraints)) {
1359
- throw new Error('Model "constraints" property must be an array.');
1360
- }
1361
- const fieldNames = new Set(modelStructure.fields.map(f => f.name));
1362
- for (const constraint of modelStructure.constraints) {
1363
- if (constraint.type === 'unique') {
1364
- if (!constraint.name || !Array.isArray(constraint.keys) || constraint.keys.length === 0) {
1365
- throw new Error('Unique constraint must have a "name" and a non-empty "keys" array.');
1366
- }
1367
- for (const key of constraint.keys) {
1368
- if (!fieldNames.has(key)) {
1369
- throw new Error(`Constraint key "${key}" in constraint "${constraint.name}" does not exist as a field in the model.`);
1370
- }
1371
- }
1372
- }
1373
- }
1374
- }
1375
-
1376
- return true; // La structure du modèle est valide
1377
- }, "event", "system");
1378
-
1379
-
1380
- // Sub modules
1381
- historyInit(defaultEngine);
1382
- }
1383
-
1384
- export const createModel = async (data) => {
1385
- return await getCollection('models').insertOne(data);
1386
- }
1387
-
1388
- export const deleteModels = async (filter) => {
1389
- return await getCollection('models').deleteMany(filter ? filter : {_user: { $exists: false }});
1390
- }
1391
-
1392
- export const getModel = async (modelName, user) => {
1393
- const modelInCache = modelsCache.get((user?.username||'')+"@@"+modelName);
1394
- if(modelInCache)
1395
- return modelInCache;
1396
- const model = await getCollection('models').findOne({name: modelName, $and: user ? [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}] : [{_user: { $exists: false}}]});
1397
- if (!model) {
1398
- throw new Error(i18n.t('api.model.notFound', {model: modelName}));
1399
- }
1400
- modelsCache.set((user?.username||'')+"@@"+modelName, model);
1401
- return model;
1402
- }
1403
- export const getModels = async () => {
1404
- return await getCollection('models')?.find({'$or': [{_user: { $exists: false}}]}).toArray() || [];
1405
- }
1406
-
1407
-
1408
- const removeValue = (obj, containsKey, removeParent=false) => {
1409
- // Base case: If the object is not an object or array, return it as is.
1410
- if (!isPlainObject(obj) && !Array.isArray(obj)) {
1411
- return obj;
1412
- }
1413
-
1414
- for (const key in obj) {
1415
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
1416
- if( removeParent ) {
1417
- const value = obj[key]?.[containsKey];
1418
- if (value !== undefined) {
1419
- delete obj[key];
1420
- } else {
1421
- removeValue(obj[key], containsKey);
1422
- }
1423
- }else if (containsKey === key) {
1424
- delete obj[key];
1425
- }else{
1426
- removeValue(obj[key], containsKey);
1427
- }
1428
- }
1429
- }
1430
- return obj;
1431
- };
1432
-
1433
- const changeValue = (obj, keyToChange, changeFunction, excludeKeys = [], depth=0, parentKey='') => {
1434
- if(!depth){
1435
- depthFilter= 0;
1436
- }
1437
- if (!isPlainObject(obj) && !Array.isArray(obj)) {
1438
- return obj;
1439
- }
1440
-
1441
- const newObj = Array.isArray(obj) ? [] : {};
1442
-
1443
- for (const key in obj) {
1444
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
1445
- const topLevel = depthFilter === 0;
1446
- if( key === keyToChange){
1447
- depthFilter++;
1448
- }
1449
- const value = obj[key];
1450
- if(value instanceof RegExp){
1451
- newObj[key] = value;
1452
- continue;
1453
- }
1454
- if (isPlainObject(value) && !excludeKeys.includes(key)) {
1455
- newObj[key] = changeValue(value, keyToChange, changeFunction, excludeKeys,depth+1, key);
1456
- } else if (Array.isArray(value) && !excludeKeys.includes(key)) {
1457
- newObj[key] = value.map(item => {
1458
- if (isPlainObject(item)) {
1459
- return changeValue(item, keyToChange, changeFunction, excludeKeys,depth+1, key);
1460
- }
1461
- return item;
1462
- });
1463
- } else {
1464
- newObj[key] = value;
1465
- }
1466
- if (key === keyToChange) {
1467
- if (typeof changeFunction === 'function') {
1468
- const newValue = changeFunction(parentKey, newObj[key], topLevel);
1469
- if (newValue !== undefined) {
1470
- if (isPlainObject(newValue)) {
1471
- return newValue;
1472
- } else {
1473
- newObj[key] = newValue;
1474
- }
1475
- }else{
1476
- //delete newObj[key];
1477
-
1478
- }
1479
- }
1480
- }
1481
- }
1482
- }
1483
- return newObj;
1484
- };
1485
-
1486
- /**
1487
- * Vérifie si l'ajout de nouvelles données dépasserait la capacité de stockage globale du serveur.
1488
- * @param {number} incomingDataSize - La taille des données entrantes en octets.
1489
- * @returns {Promise<{isSufficient: boolean, free?: number, total?: number, error?: string}>}
1490
- */
1491
- export async function checkServerCapacity(incomingDataSize = 0) {
1492
- try {
1493
- const diskSpace = await checkDiskSpace(DATA_STORAGE_PATH);
1494
- const { free, size } = diskSpace;
1495
-
1496
- // Limite maximale d'utilisation du disque (ex: 90% de la taille totale)
1497
- const maxAllowedUsage = size * storageSafetyMargin;
1498
- const currentUsage = size - free;
1499
- const projectedUsage = currentUsage + incomingDataSize;
1500
-
1501
- if (projectedUsage > maxAllowedUsage) {
1502
- logger.warn(`[checkServerCapacity] Alert: Projected usage (${projectedUsage} bytes) would exceed the server's safety limit (${maxAllowedUsage} bytes).`);
1503
- return {
1504
- isSufficient: false,
1505
- free,
1506
- total: size
1507
- };
1508
- }
1509
- return { isSufficient: true, free, total: size };
1510
- } catch (err) {
1511
- logger.error(`[checkServerCapacity] CRITICAL: Failed to check disk space: ${err.message}. Allowing write operation as a failsafe. Please investigate disk permissions or configuration.`);
1512
- // Failsafe: On autorise l'ure si la vérification échoue, mais on logue une erreur critique.
1513
- return { isSufficient: true, error: 'Could not verify disk space.' };
1514
- }
1515
- }
1516
-
1517
- export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true) => {
1518
-
1519
- // --- Vérification des permissions (inchangée) ---
1520
- if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && (
1521
- !await hasPermission(["API_ADMIN", "API_ADD_DATA", "API_ADD_DATA_" + modelName], user) ||
1522
- await hasPermission(["API_ADD_DATA_NOT_" + modelName], user))) {
1523
- // Renvoyer une structure d'erreur cohérente
1524
- return { success: false, error: i18n.t('api.permission.addData'), statusCode: 403 };
1525
- }
1526
-
1527
- const collection = await getCollectionForUser(user);
1528
- let insertedIds = []; // Pour stocker les IDs retourn par pushDataUnsecure
1529
-
1530
- try {
1531
- // --- Insertion via pushDataUnsecure (inchangée) ---
1532
- insertedIds = await pushDataUnsecure(data, modelName, user, files);
1533
-
1534
- // Check if pushDataUnsecure actually returned IDs
1535
- if (!insertedIds || insertedIds.length === 0) {
1536
- logger.warn(`[insertData] pushDataUnsecure did not return inserted IDs for model ${modelName}.`);
1537
- // Retourner échec si l'insertion n'a pas retourné d'IDs
1538
- return { success: false, unmodified: true, error: "Insertion failed, no IDs returned by core function.", statusCode: 500 };
1539
- }
1540
-
1541
- // Convertir les IDs en ObjectId pour la recherche
1542
- const objectIds = insertedIds.map(id => new ObjectId(id));
1543
- const insertedDocs = await collection.find({ _id: { $in: objectIds } }).toArray();
1544
-
1545
- if (!insertedDocs || insertedDocs.length === 0) {
1546
- logger.warn(`[insertData] Could not fetch inserted documents after pushDataUnsecure (IDs: ${insertedIds.join(', ')}).`);
1547
- // Continuer même si on ne peut pas fetch, l'insertion a réussi.
1548
- } else {
1549
- // --- Logique de post-insertion (Workflows et Planification) ---
1550
- const postInsertionPromises = insertedDocs.map(async (doc) => {
1551
- // 1. Déclencher les workflows 'DataAdded' (si activé)
1552
- if (triggerWorkflow) {
1553
- const prom = triggerWorkflows(doc, user, 'DataAdded').then(e => {
1554
- logger.debug(`[insertData] Triggered DataAdded workflow for ${doc._model} ID ${doc._id}.`);
1555
- }).catch(e => {
1556
- logger.error(`[insertData] Error triggering DataAdded workflow for ${doc._model} ID ${doc._id}:`, e);
1557
- });
1558
- if( waitForWorkflow){
1559
- await prom;
1560
- }
1561
- }
1562
-
1563
- if (doc._model === 'workflowTrigger' && doc.isActive === true && doc.cronExpression) {
1564
- const jobId = `workflowTrigger_${doc._id}`;
1565
- logger.info(`[insertData] Scheduling new active workflowTrigger ${doc._id} (${doc.name || 'No Name'}) with cron: "${doc.cronExpression}"`);
1566
-
1567
- if (schedule.scheduledJobs[jobId]) {
1568
- logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
1569
- schedule.scheduledJobs[jobId].cancel();
1570
- }
1571
-
1572
- try {
1573
- schedule.scheduleJob(jobId, doc.cronExpression, async () => {
1574
- logger.info(`[Scheduled Job] Cron triggered for job ${jobId}. Attempting to run with lock...`);
1575
- await runScheduledJobWithDbLock(
1576
- jobId,
1577
- async () => {
1578
- // --- NOUVELLE LOGIQUE D'ALERTE ---
1579
- logger.info(`[Scheduled Job] Executing alert logic for workflow ${doc.name} (ID: ${doc._id}) for user ${doc._user}`);
1580
-
1581
- const alertPayload = {
1582
- type: 'cron_alert',
1583
- triggerId: doc._id.toString(),
1584
- triggerName: doc.name,
1585
- timestamp: new Date().toISOString(),
1586
- message: `L'alerte planifiée '${doc.name || 'Sans nom'}' a été déclenchée.`
1587
- };
1588
-
1589
- // Envoyer l'alerte à l'utilisateur spécifique via SSE
1590
- const sent = await sendSseToUser(doc._user, alertPayload);
1591
-
1592
- if (sent) {
1593
- logger.info(`[Scheduled Job] Successfully sent SSE alert for job ${jobId} to user ${doc._user}.`);
1594
- } else {
1595
- logger.warn(`[Scheduled Job] Could not send SSE alert for job ${jobId}. User ${doc._user} is not connected via SSE.`);
1596
- }
1597
- // --- FIN DE LA LOGIQUE D'ALERTE ---
1598
- },
1599
- doc.lockDurationMinutes || 5
1600
- );
1601
- });
1602
- logger.info(`[insertData] Successfully scheduled job ${jobId}.`);
1603
- } catch (scheduleError) {
1604
- logger.error(`[insertData] Failed to schedule job ${jobId} for workflowTrigger ${doc._id}. Error: ${scheduleError.message}`);
1605
- }
1606
- }
1607
- else if (doc._model === 'alert' && doc.isActive === true && doc.frequency) {
1608
- const jobId = `alert_${doc._id}`;
1609
- logger.info(`[insertData] Scheduling new active alert ${doc._id} (${doc.name}) with frequency: "${doc.frequency}"`);
1610
-
1611
- if (schedule.scheduledJobs[jobId]) {
1612
- logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
1613
- schedule.scheduledJobs[jobId].cancel();
1614
- }
1615
-
1616
- try {
1617
- // --- MODIFICATION ICI ---
1618
- schedule.scheduleJob(jobId, doc.frequency, () => runStatefulAlertJob(doc._id));
1619
- // --- FIN MODIFICATION ---
1620
- logger.info(`[insertData] Successfully scheduled alert job ${jobId}.`);
1621
- } catch (scheduleError) {
1622
- logger.error(`[insertData] Failed to schedule alert job ${jobId}. Error: ${scheduleError.message}`);
1623
- }
1624
- }
1625
-
1626
- });
1627
-
1628
- // Attendre que toutes les opérations post-insertion (workflows, planification) soient tentées
1629
- await Promise.allSettled(postInsertionPromises);
1630
- }
1631
-
1632
- // System specific event
1633
- const eventPayload = { modelName, insertedIds, user };
1634
- await Event.Trigger("OnDataAdded", "event", "system", engine, eventPayload);
1635
-
1636
- // User specific event
1637
- const userPayload = {...eventPayload};
1638
- delete userPayload['user'];
1639
- await Event.Trigger("OnDataAdded", "event", "user", userPayload);
1640
-
1641
- // Return valid result
1642
- return { success: true, insertedIds: insertedIds.map(id => id.toString()) }; // Convertir les IDs en string pour la réponse
1643
-
1644
- } catch (error) { // Attrape les erreurs de permission ou de pushDataUnsecure
1645
- logger.error(`[insertData] Main error during insertion process for model ${modelName}: ${error.message}`, error.stack);
1646
- // Renvoyer une structure d'erreur cohérente
1647
- return { success: false, unmodified: error.unmodified, error: error.message || "Insertion failed due to an unexpected error.", statusCode: error.statusCode || 500 };
1648
- }
1649
- };
1650
-
1651
- /**
1652
- * Fonction principale pour l'insertion de données avec gestion des relations
1653
- * @param {Array<object>|object} data - Données à insérer
1654
- * @param {string} modelName - Nom du modèle cible
1655
- * @param {object} me - Utilisateur courant
1656
- * @param {object} [files={}] - Fichiers associés (optionnel)
1657
- * @returns {Promise<Array<string>>} IDs des documents insérés/trouvés
1658
- */
1659
- export const pushDataUnsecure = async (data, modelName, me, files = {}) => {
1660
- try {
1661
- // 1. Initialisation et validation
1662
- const {datas, model, collection} = await initializeAndValidate(data, modelName, me);
1663
- if (datas.length === 0) {
1664
- return [];
1665
- }
1666
-
1667
- // 2. Vérification des limites (en parallèle avec les contraintes)
1668
- const [_, violations] = await Promise.all([
1669
- checkLimits(datas, model, collection, me),
1670
- checkCompositeUniqueConstraints(datas, model, collection, me)
1671
- ]);
1672
-
1673
- if (violations.length > 0) {
1674
- throw new Error(`Violation of unique constraints :\n${violations.join('\n')}`);
1675
- }
1676
-
1677
- // 3. Traitement des documents
1678
- const {allInsertedIds, idMap} = await processDocuments(datas, model, collection, me);
1679
-
1680
- // 4. Gestion des fichiers (optionnel)
1681
- await handleFilesIfNeeded(allInsertedIds, files, model, collection);
1682
-
1683
- return allInsertedIds;
1684
- } catch (e) {
1685
- throw e;
1686
- }
1687
- };
1688
-
1689
- async function checkCompositeUniqueConstraints(datas, model, collection, user) {
1690
- if (!model.constraints?.length) return [];
1691
-
1692
- const uniqueConstraints = model.constraints.filter(c => c.type === 'unique' && c.keys?.length);
1693
- if (!uniqueConstraints.length) return [];
1694
-
1695
- // Préparation des vérifications
1696
- const violations = [];
1697
- const userId = user._user || user.username;
1698
-
1699
- // Paralléliser par contrainte
1700
- await Promise.all(uniqueConstraints.map(async (constraint) => {
1701
- // Vérifier les champs de la contrainte
1702
- const invalidFields = constraint.keys.filter(key =>
1703
- !model.fields.some(f => f.name === key)
1704
- );
1705
-
1706
- if (invalidFields.length) {
1707
- violations.push(`Fields used in constraint [${invalidFields.join(', ')}] '${constraint.name}' are inexistant.`);
1708
- return;
1709
- }
1710
-
1711
- // Batch processing des documents (500 max)
1712
- const batchSize = 50;
1713
- for (let i = 0; i < datas.length; i += batchSize) {
1714
- const batch = datas.slice(i, i + batchSize);
1715
-
1716
- // Créer toutes les requêtes pour ce batch
1717
- const queries = batch.flatMap(doc => {
1718
- const compositeKey = {};
1719
- let hasNull = false;
1720
-
1721
- for (const key of constraint.keys) {
1722
- if (doc[key] == null) {
1723
- hasNull = true;
1724
- break;
1725
- }
1726
- compositeKey[key] = doc[key];
1727
- }
1728
-
1729
- return hasNull ? [] : [{
1730
- collection,
1731
- query: {
1732
- ...compositeKey,
1733
- _model: model.name,
1734
- _user: userId
1735
- }
1736
- }];
1737
- });
1738
-
1739
- // Exécution en parallèle avec $or pour réduire les appels
1740
- if (queries.length) {
1741
- const matchingDocs = await collection.find({
1742
- $or: queries.map(q => q.query)
1743
- }).toArray();
1744
-
1745
- if (matchingDocs.length) {
1746
- // Créer un Set des clés existantes pour recherche rapide
1747
- const existingKeys = new Set(
1748
- matchingDocs.map(doc =>
1749
- constraint.keys.map(k => doc[k]).join('|')
1750
- )
1751
- );
1752
-
1753
- // Vérifier chaque document du batch
1754
- batch.forEach(doc => {
1755
- const keyValues = constraint.keys.map(k => doc[k]);
1756
- if (keyValues.every(v => v != null)) {
1757
- const compositeKey = keyValues.join('|');
1758
- if (existingKeys.has(compositeKey)) {
1759
- violations.push(
1760
- `[${constraint.name}] Existing data : ${constraint.keys.map((k, i) => `${k}=${keyValues[i]}`).join(', ')}`
1761
- );
1762
- }
1763
- }
1764
- });
1765
- }
1766
- }
1767
- }
1768
- }));
1769
-
1770
- return violations;
1771
- }
1772
-
1773
- /**
1774
- * Initialise et valide les paramètres d'entrée
1775
- */
1776
- async function initializeAndValidate(data, modelName, me) {
1777
- const datas = normalizeInputData(data);
1778
- if (datas.length === 0) return { datas: [], model: null, collection: null };
1779
-
1780
- const model = await getModel(modelName, me);
1781
- const collection = await getCollectionForUser(me);
1782
- await validateModelStructure(model);
1783
-
1784
- return { datas, model, collection };
1785
- }
1786
-
1787
- /**
1788
- * Normalise les données d'entrée (tableau ou objet unique)
1789
- */
1790
- function normalizeInputData(data) {
1791
- if (Array.isArray(data)) return data;
1792
- if (isPlainObject(data)) return [data];
1793
- return [];
1794
- }
1795
-
1796
- /**
1797
- * Vérifie toutes les limites (stockage, capacité, etc.)
1798
- */
1799
- async function checkLimits(datas, model, collection, me) {
1800
- const incomingDataSize = calculateDataSize(datas);
1801
- const userStorageLimit = await engine.userProvider.getUserStorageLimit(me);
1802
-
1803
- // Vérification des limites utilisateur
1804
- const currentStorageUsage = await calculateTotalUserStorageUsage(me);
1805
- if (currentStorageUsage + incomingDataSize > userStorageLimit) {
1806
- throw new Error(i18n.t("api.data.storageLimitExceeded", {
1807
- limit: Math.round(userStorageLimit / megabytes)
1808
- }));
1809
- }
1810
-
1811
- // Vérification capacité serveur
1812
- const serverCapacity = await checkServerCapacity(incomingDataSize);
1813
- if (!serverCapacity.isSufficient) {
1814
- throw new Error(i18n.t("api.data.serverStorageFull"));
1815
- }
1816
-
1817
- // Vérification nombre max de documents
1818
- const count = await collection.countDocuments({ _user: me._user || me.username });
1819
- if (count + datas.length > maxTotalDataPerUser) {
1820
- throw new Error(i18n.t("api.data.tooManyData"));
1821
- }
1822
-
1823
- if (datas.length > maxPostData) {
1824
- throw new Error(i18n.t('api.data.tooManyData'));
1825
- }
1826
- }
1827
-
1828
- /**
1829
- * Calcule la taille des données (en octets)
1830
- */
1831
- function calculateDataSize(datas) {
1832
- try {
1833
- return BSON.calculateObjectSize(datas);
1834
- } catch (e) {
1835
- logger.warn("[Storage] Fallback to JSON.stringify for size estimation.");
1836
- return JSON.stringify(datas).length;
1837
- }
1838
- }
1839
-
1840
-
1841
- async function processDocuments(datas, model, collection, me) {
1842
- const idMap = new Map();
1843
- const allInsertedIds = [];
1844
-
1845
- const realData = await Event.Trigger("OnDataInsert", "event", "system", datas) || datas;
1846
- for (const doc of realData) {
1847
- try {
1848
- const newDocId = await insertAndResolveRelations(doc, model, collection, me, idMap);
1849
- if (newDocId) {
1850
- allInsertedIds.push(newDocId.toString());
1851
- }
1852
- } catch (error) {
1853
- // Modification clé ici : on ne catch plus les erreurs de validation
1854
- throw error;
1855
- }
1856
- }
1857
-
1858
- return { allInsertedIds, idMap };
1859
- }
1860
-
1861
-
1862
- /**
1863
- * Traite toutes les relations du document
1864
- */
1865
- async function processRelations(docToProcess, model, collection, me, idMap) {
1866
- const batchFinds = [];
1867
-
1868
- // Phase 1: Préparation des requêtes
1869
- for (const field of model.fields) {
1870
- if (field.type !== 'relation') continue;
1871
-
1872
- const value = docToProcess[field.name];
1873
- if (value?.$find) {
1874
- batchFinds.push({
1875
- field: field.name,
1876
- promise: searchData({
1877
- filter: value.$find,
1878
- limit: field.multiple ? 0 : 1,
1879
- model: field.relation
1880
- }, me),
1881
- multiple: field.multiple
1882
- });
1883
- }
1884
- }
1885
-
1886
- // Phase 2: Exécution parallèle
1887
- const findResults = await Promise.all(batchFinds.map(f => f.promise));
1888
-
1889
- // Phase 3: Traitement des résultats
1890
- findResults.forEach((result, index) => {
1891
- const { field, multiple } = batchFinds[index];
1892
- if (result.data?.length > 0) {
1893
- // Cas où des documents sont trouvés
1894
- docToProcess[field] = multiple
1895
- ? result.data.map(r => r._id.toString())
1896
- : result.data[0]._id.toString();
1897
- } else {
1898
- // Cas où AUCUN document n'est trouvé : il faut nettoyer le champ !
1899
- docToProcess[field] = multiple ? [] : null;
1900
- }
1901
- });
1902
-
1903
-
1904
- for (const field of model.fields) {
1905
- if (field.type !== 'relation') continue;
1906
-
1907
- const fieldName = field.name;
1908
- const relationValue = docToProcess[fieldName];
1909
- if (!relationValue || typeof relationValue !== 'object') continue;
1910
-
1911
- const relatedModel = await getModel(field.relation, me);
1912
-
1913
- if( !Array.isArray(relationValue) && relationValue['$find'] ) {
1914
-
1915
- }else if (Array.isArray(relationValue)) {
1916
- // Relation multiple (tableau)
1917
- docToProcess[fieldName] = await processMultipleRelations(
1918
- relationValue,
1919
- relatedModel,
1920
- collection,
1921
- me,
1922
- idMap
1923
- );
1924
- } else if (isPlainObject(relationValue)) {
1925
- // Relation simple (objet)
1926
- docToProcess[fieldName] = await processSingleRelation(
1927
- relationValue,
1928
- relatedModel,
1929
- collection,
1930
- me,
1931
- idMap
1932
- );
1933
- }
1934
- }
1935
- }
1936
-
1937
- /**
1938
- * Traite une relation multiple (tableau)
1939
- */
1940
- async function processMultipleRelations(items, relatedModel, collection, me, idMap) {
1941
- const newRelationIds = await Promise.all(
1942
- items.map(item => processRelationItem(item, relatedModel, collection, me, idMap))
1943
- );
1944
- return newRelationIds.filter(id => id).map(id => id.toString());
1945
- }
1946
-
1947
- /**
1948
- * Traite une relation simple (objet)
1949
- */
1950
- async function processSingleRelation(item, relatedModel, collection, me, idMap) {
1951
- const newId = await processRelationItem(item, relatedModel, collection, me, idMap);
1952
- return newId ? newId.toString() : null;
1953
- }
1954
-
1955
-
1956
- async function processRelationItem(item, relatedModel, collection, me, idMap) {
1957
- // Cas 1: ID existant (string ou ObjectId)
1958
- if (isObjectId(item) || typeof item === 'string') {
1959
- const originalId = typeof item === 'string' ? item : item.toString();
1960
-
1961
- // Vérifier si cet ID a déjà été mappé (cas d'une référence circulaire)
1962
- if (idMap.has(originalId)) {
1963
- return idMap.get(originalId);
1964
- }
1965
-
1966
- // Sinon, vérifier si l'ID existe en base
1967
- const existing = await collection.findOne({
1968
- _id: new ObjectId(originalId),
1969
- _model: relatedModel.name,
1970
- $or: [{_user: me._user || me.username}, {_user: {$exists: false}}]
1971
- });
1972
-
1973
- if (existing) {
1974
- return existing._id; // Conserver l'ID original
1975
- }
1976
- }
1977
-
1978
- // Cas 2: Objet complet à importer
1979
- if (isPlainObject(item)) {
1980
- const relationDoc = prepareDocument(item, relatedModel, me);
1981
- applyDefaultValues(relationDoc, relatedModel);
1982
-
1983
- // Si l'objet a un _id, essayer de le conserver
1984
- if (item._id) {
1985
- const originalId = item._id.toString();
1986
-
1987
- // Vérifier si l'ID existe déjà en base
1988
- const existing = await collection.findOne({
1989
- _id: new ObjectId(originalId),
1990
- _model: relatedModel.name,
1991
- $or: [{_user: me._user || me.username}, {_user: {$exists: false}}]
1992
- });
1993
-
1994
- if (existing) {
1995
- return existing._id; // Utiliser l'ID existant
1996
- }
1997
-
1998
- // Si l'ID n'existe pas encore, l'utiliser pour le nouvel insert
1999
- relationDoc._id = new ObjectId(originalId);
2000
- }
2001
-
2002
- const relationHash = relationDoc._hash;
2003
- const cacheKey = `${relatedModel.name}:${relationHash}`;
2004
-
2005
- // Vérification dans le cache
2006
- const cachedId = relationCache.get(cacheKey);
2007
- if (cachedId !== undefined) {
2008
- return cachedId;
2009
- }
2010
-
2011
- // Vérification en base de données par hash
2012
- const existingByHash = await collection.findOne({
2013
- _hash: relationHash,
2014
- _model: relatedModel.name,
2015
- _user: relationDoc._user
2016
- }, { projection: { _id: 1 } });
2017
-
2018
- if (existingByHash) {
2019
- relationCache.set(cacheKey, existingByHash._id);
2020
- return existingByHash._id;
2021
- }
2022
-
2023
- const newId = await insertAndResolveRelations(item, relatedModel, collection, me, idMap);
2024
- relationCache.set(cacheKey, newId);
2025
- return newId;
2026
- }
2027
-
2028
- return null;
2029
- }
2030
- // Fonction pour vider le cache si besoin
2031
- function clearRelationCache() {
2032
- relationCache.flushAll();
2033
- }
2034
-
2035
- // Fonction pour obtenir les stats du cache (utile pour le debug)
2036
- function getCacheStats() {
2037
- return relationCache.getStats();
2038
- }
2039
-
2040
- /**
2041
- * Applique les filtres de champ définis dans le modèle
2042
- */
2043
- async function applyFieldFilters(docToProcess, model) {
2044
- for (const field of model.fields) {
2045
- docToProcess[field.name] = typeof(docToProcess[field.name]) === 'undefined' || docToProcess[field.name] === null ? field.default:docToProcess[field.name];
2046
- if (dataTypes[field.type]?.filter) {
2047
- const filter = await dataTypes[field.type].filter(
2048
- docToProcess[field.name],
2049
- field
2050
- );
2051
- const realFilter = await Event.Trigger('OnDataFilter', "event", "system",filter, field, docToProcess );
2052
- docToProcess[field.name] = realFilter || filter;
2053
- }
2054
- }
2055
- }
2056
-
2057
-
2058
- /**
2059
- * Valide la structure et le contenu du document selon le modèle
2060
- */
2061
- async function validateModelData(doc, model, isPatch = false) {
2062
- if (!isPatch) {
2063
- model.fields.forEach(field => {
2064
- const value = doc[field.name];
2065
- if (field.required) {
2066
- if (value === undefined && !('default' in field)) {
2067
- throw new Error(i18n.t('api.field.missingRequired', { field: field.name + " (" + model.name + ")" }));
2068
- }
2069
- if (value === '' || value === null) {
2070
- throw new Error(i18n.t('api.field.requiredCannotBeEmpty', { field: field.name }));
2071
- }
2072
- }
2073
- });
2074
- }
2075
-
2076
- // 2. Validation des types de champs (toujours exécutée pour les champs fournis)
2077
- for (const [fieldName, value] of Object.entries(doc)) {
2078
- const fieldDef = model.fields.find(f => f.name === fieldName);
2079
- if (!fieldDef) continue; // On ignore les champs supplémentaires
2080
-
2081
- const validator = dataTypes[fieldDef.type]?.validate;
2082
- const valid = validator && validator(value, fieldDef);
2083
- const realValidation = await Event.Trigger('OnDataValidate', "event", "system", value, fieldDef,doc );
2084
- if (!(valid || realValidation)) {
2085
- throw new Error(i18n.t('api.field.validationFailed', { field: fieldName, value }));
2086
- }
2087
- }
2088
- }
2089
-
2090
- /**
2091
- * Applique les valeurs par défaut aux champs manquants
2092
- */
2093
- function applyDefaultValues(doc, model) {
2094
- for (const field of model.fields) {
2095
- // Si le champ n'est pas défini et a une valeur par défaut
2096
- if (!(field.name in doc) && 'default' in field) {
2097
- doc[field.name] = typeof field.default === 'function'
2098
- ? field.default()
2099
- : field.default;
2100
- }
2101
- }
2102
- }
2103
-
2104
- async function insertAndResolveRelations(doc, model, collection, me, idMap) {
2105
- const originalId = doc._id?.toString();
2106
-
2107
- // Si cet ID a déjà été traité, retourner le nouvel ID mappé
2108
- if (originalId && idMap.has(originalId)) {
2109
- return idMap.get(originalId);
2110
- }
2111
-
2112
- const docToProcess = prepareDocument(doc, model, me);
2113
- applyDefaultValues(docToProcess, model);
2114
-
2115
- // Si le document a un _id original et qu'il n'existe pas encore, le conserver
2116
- if (originalId && !await collection.findOne({ _id: new ObjectId(originalId) })) {
2117
- docToProcess._id = new ObjectId(originalId);
2118
- }
2119
-
2120
- await validateModelData(docToProcess, model);
2121
- await processRelations(docToProcess, model, collection, me, idMap);
2122
- await validateModelData(docToProcess, model);
2123
- await applyFieldFilters(docToProcess, model);
2124
- await checkUniqueFields(docToProcess, model, collection);
2125
-
2126
- const existingDoc = await findExistingDocument(docToProcess, collection);
2127
- if (existingDoc) {
2128
- cacheDocumentId(originalId, existingDoc._id, idMap);
2129
- return existingDoc._id;
2130
- }
2131
-
2132
- for (const field of model.fields) {
2133
- if (field.type === 'relation' && field.relationFilter && docToProcess[field.name]) {
2134
-
2135
- const relatedIds = Array.isArray(docToProcess[field.name])
2136
- ? docToProcess[field.name]
2137
- : [docToProcess[field.name]];
2138
-
2139
- // Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
2140
- const validationQuery = {
2141
- $and: [
2142
- { $in: ['$_id', relatedIds.map(id => ({ $toObjectId: id }))] },
2143
- field.relationFilter
2144
- ]
2145
- };
2146
-
2147
- const relatedDocs = await searchData({
2148
- filter: validationQuery,
2149
- model: field.relation,
2150
- limit: relatedIds.length
2151
- }, me);
2152
-
2153
- if ((relatedDocs?.count || 0) !== relatedIds.length) {
2154
- const invalidIds = relatedIds.filter(id =>
2155
- !relatedDocs.data.some(doc => doc._id.toString() === id.toString())
2156
- );
2157
- throw new Error(
2158
- i18n.t(
2159
- 'api.data.relationFilterFailed',
2160
- 'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
2161
- { field: field.name, values: invalidIds.join(', ') }
2162
- )
2163
- );
2164
- }
2165
- }
2166
- }
2167
-
2168
-
2169
- // Insertion en conservant éventuellement l'ID original
2170
- const result = docToProcess._id
2171
- ? await collection.insertOne(docToProcess)
2172
- : await collection.insertOne(docToProcess);
2173
-
2174
- const insertedId = result.insertedId;
2175
- cacheDocumentId(originalId, insertedId, idMap);
2176
-
2177
- return insertedId;
2178
- }
2179
-
2180
- // Nouvelle fonction pour vérifier les champs uniques
2181
- async function checkUniqueFields(doc, model, collection) {
2182
- const uniqueFields = model.fields.filter(f => f.unique);
2183
-
2184
- for (const field of uniqueFields) {
2185
- const value = doc[field.name];
2186
- if (value === undefined || value === null) continue;
2187
-
2188
- const existing = await collection.findOne({
2189
- [field.name]: value,
2190
- _model: model.name,
2191
- _user: doc._user
2192
- });
2193
-
2194
- if (existing) {
2195
- // Utilisation de i18n pour un message d'erreur standardisé
2196
- throw new Error(i18n.t('api.data.duplicateValue', { field: field.name, value: value }));
2197
- }
2198
- }
2199
- }
2200
-
2201
- function prepareDocument(doc, model, me) {
2202
- const docToProcess = { ...doc };
2203
- delete docToProcess._id;
2204
-
2205
- // AJOUT: Nettoyage des champs non définis dans le modèle
2206
- for (const key of Object.keys(docToProcess)) {
2207
- if (!model.fields.some(f => f.name === key) && !key.startsWith('_')) {
2208
- delete docToProcess[key];
2209
- }
2210
- }
2211
-
2212
- docToProcess._model = model.name;
2213
- docToProcess._user = me._user || me.username;
2214
- docToProcess._hash = getFieldValueHash(model, docToProcess);
2215
-
2216
- return docToProcess;
2217
- }
2218
- /**
2219
- * Cherche un document existant par son hash
2220
- */
2221
- async function findExistingDocument(docToProcess, collection) {
2222
- return await collection.findOne({
2223
- _hash: docToProcess._hash,
2224
- _model: docToProcess._model,
2225
- _user: docToProcess._user
2226
- });
2227
- }
2228
-
2229
- /**
2230
- * Insère le document dans la collection
2231
- */
2232
- async function insertDocument(docToProcess, collection) {
2233
- const result = await collection.insertOne(docToProcess);
2234
- return result.insertedId;
2235
- }
2236
-
2237
- /**
2238
- * Met en cache la correspondance d'ID
2239
- */
2240
- function cacheDocumentId(originalId, newId, idMap) {
2241
- if (originalId && newId) {
2242
- idMap.set(originalId, newId);
2243
- }
2244
- }
2245
-
2246
- /**
2247
- * Gestion des fichiers (à implémenter selon besoins)
2248
- */
2249
- async function handleFilesIfNeeded(insertedIds, files, model, collection) {
2250
- // Implémentation spécifique à votre application
2251
- // Ex: association des fichiers uploadés aux documents insérés
2252
- }
2253
- const checkHash = async (me, model, hash, excludeId = null) => {
2254
- const collection = await getCollectionForUser(me);
2255
- const query = {
2256
- _model: model.name,
2257
- _hash: hash,
2258
- ...(excludeId && { _id: { $ne: new ObjectId(excludeId) } })
2259
- };
2260
-
2261
- console.log("Query being executed:", JSON.stringify(query, null, 2));
2262
-
2263
- const count = await collection.countDocuments(query);
2264
- return count > 0;
2265
- };
2266
-
2267
-
2268
- export const getResource = async (guid, user) => {
2269
- if (!guid) throw new Error("Le GUID du fichier est requis.");
2270
- if (!isGUID(guid)) throw new Error("Le GUID du fichier n'est pas valide.");
2271
-
2272
- const collection = getCollection("files");
2273
- const file = await collection.findOne({ guid });
2274
-
2275
- if (!file) {
2276
- throw new Error("Fichier non trouvé.");
2277
- }
2278
-
2279
- // La vérification des permissions reste la même...
2280
- if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_READ_FILE", `API_READ_FILE_privateFile_${guid}`], user)) {
2281
- if (file.user !== (user._user || user.username)) {
2282
- throw new Error("Vous n'êtes pas autorisé à accéder à ce fichier.");
2283
- }
2284
- }
2285
-
2286
- // On retourne des informations différentes selon le type de stockage
2287
- if (file.storage === 's3') {
2288
- return {
2289
- success: true,
2290
- storage: 's3',
2291
- s3Key: file.filename, // 'filename' contient la clé S3
2292
- mimeType: file.mimeType
2293
- // Idlement, on aurait aussi le nom de fichier original ici
2294
- };
2295
- } else { // Par défaut, on considère le stockage local
2296
- // On utilise le chemin stocké en base de données
2297
- const filepath = file.path;
2298
- if (!filepath || !fs.existsSync(filepath)) {
2299
- throw new Error("Fichier non trouvé sur le serveur.");
2300
- }
2301
- return {
2302
- success: true,
2303
- storage: 'local',
2304
- filepath: filepath,
2305
- filename: file.filename,
2306
- mimeType: file.mimeType
2307
- };
2308
- }
2309
- };
2310
-
2311
- export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
2312
- return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
2313
- };
2314
-
2315
- export const editData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
2316
- return await internalEditOrPatchData(modelName, filter, data, files, user, false, triggerWorkflow, waitForWorkflow);
2317
- };
2318
-
2319
- const internalEditOrPatchData = async (modelName, filter, data, files, user, isPatch, triggerWorkflow = true, waitForWorkflow = false) => {
2320
- try {
2321
- // 1. Vérification des permissions
2322
- if (user.username !== 'demo' && isLocalUser(user) && (
2323
- !await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_" + modelName], user) ||
2324
- await hasPermission(["API_EDIT_DATA_NOT_" + modelName], user))) {
2325
- throw new Error(i18n.t("api.permission.editData"));
2326
- }
2327
-
2328
- const collection = await getCollectionForUser(user);
2329
- const model = await modelsCollection.findOne({name: modelName, _user: user.username});
2330
- if (!model) {
2331
- throw new Error(i18n.t("api.model.notFound", {model: modelName}));
2332
- }
2333
-
2334
- // 2. Récupération des documents existants et de leur hash original
2335
- const existingDocs = (await searchData({model: modelName, filter}, user))?.data;
2336
- if (!existingDocs || existingDocs.length === 0) {
2337
- return {success: false, error: i18n.t("api.data.notFound")};
2338
- }
2339
- const ids = existingDocs.map(d => new ObjectId(d._id));
2340
- const originalHash = existingDocs[0]._hash; // Sauvegarde du hash avant modification
2341
-
2342
- // 3. Préparation des données de mise à jour (inchangé)
2343
- const updateData = {...data};
2344
- delete updateData._model;
2345
- delete updateData._user;
2346
-
2347
- // Traitement des fichiers (inchangé)
2348
- const fileFields = model.fields.filter(f => f.type === 'file' || (f.type === 'array' && f.itemsType === 'file'));
2349
- for (const field of fileFields) {
2350
- if (files?.[field.name+'[0]']) {
2351
- if (field.type === 'file') {
2352
- updateData[field.name] = await addFile(files[field.name+'[0]'][0], user);
2353
- } else if (field.type === 'array' && field.itemsType === 'file') {
2354
- const currentFiles = existingDocs[0]?.[field.name] || [];
2355
- const newFiles = await processFileArray(files[field.name+'[0]'], currentFiles, user);
2356
- updateData[field.name] = newFiles;
2357
- }
2358
- }
2359
- }
2360
-
2361
- // 4. Validation adaptée pour patch ou edit (inchangé)
2362
- if (!isPatch) {
2363
- const dataToValidate = { ...existingDocs[0], ...updateData };
2364
- await validateModelData(dataToValidate, model, false);
2365
- } else {
2366
- await validateModelData(updateData, model, true);
2367
- }
2368
-
2369
- // 5. Vérification des champs uniques (inchangé)
2370
- const uniqueFields = model.fields.filter(f => f.unique);
2371
- for (const field of uniqueFields) {
2372
- if (updateData[field.name] !== undefined) {
2373
- const existing = await collection.findOne({
2374
- _user: user._user || user.username,
2375
- _model: modelName,
2376
- [field.name]: updateData[field.name],
2377
- _id: {$nin: ids}
2378
- });
2379
- if (existing) {
2380
- throw new Error(i18n.t("api.data.duplicateValue", { field: field.name, value: updateData[field.name] }));
2381
- }
2382
- }
2383
- }
2384
-
2385
- // 6. Traitement des relations (inchangé)
2386
- const relationFields = model.fields.filter(f => f.type === 'relation');
2387
- for (const field of relationFields) {
2388
- if (updateData[field.name] !== undefined) {
2389
- const relationValue = updateData[field.name];
2390
- // Only process relations if the value is an object or an array containing at least one object.
2391
- // An array of strings (ObjectIDs) should be passed through as-is for the update.
2392
- let shouldProcessRelation = false;
2393
- if (Array.isArray(relationValue)) {
2394
- // If any item in the array is a plain object, we need to process the whole array
2395
- // to handle potential nested creations or lookups.
2396
- if (relationValue.some(item => isPlainObject(item))) {
2397
- shouldProcessRelation = true;
2398
- }
2399
- } else if (isPlainObject(relationValue)) {
2400
- shouldProcessRelation = true;
2401
- }
2402
- if (shouldProcessRelation) {
2403
- const insertedIds = await pushDataUnsecure(relationValue, field.relation, user);
2404
- updateData[field.name] = field.multiple ? insertedIds || [] : (insertedIds?.[0] || null);
2405
- }
2406
- }
2407
- }
2408
-
2409
- // 7. Application des filtres de champ (ex: hashage de mot de passe) (inchangé)
2410
- for (const field of model.fields) {
2411
- if (updateData[field.name] !== undefined && dataTypes[field.type]?.filter) {
2412
- updateData[field.name] = await dataTypes[field.type].filter(
2413
- field.type === 'file' ? null : updateData[field.name],
2414
- field
2415
- );
2416
- }
2417
- }
2418
-
2419
- for (const field of model.fields) {
2420
- if (field.type === 'relation' && field.relationFilter && updateData[field.name]) {
2421
-
2422
- const relatedIds = Array.isArray(updateData[field.name])
2423
- ? updateData[field.name]
2424
- : [updateData[field.name]];
2425
-
2426
- // Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
2427
- const validationQuery = {
2428
- $and: [
2429
- { $in: ['$_id', relatedIds.map(id => ({ $toObjectId: id }))] },
2430
- field.relationFilter
2431
- ]
2432
- };
2433
-
2434
- const relatedDocs = await searchData({
2435
- filter: validationQuery,
2436
- model: field.relation,
2437
- limit: relatedIds.length
2438
- }, user);
2439
-
2440
- if ((relatedDocs?.count || 0) !== relatedIds.length) {
2441
- const invalidIds = relatedIds.filter(id =>
2442
- !relatedDocs.data.some(doc => doc._id.toString() === id.toString())
2443
- );
2444
- throw new Error(
2445
- i18n.t(
2446
- 'api.data.relationFilterFailed',
2447
- 'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
2448
- { field: field.name, values: invalidIds.join(', ') }
2449
- )
2450
- );
2451
- }
2452
- }
2453
- }
2454
-
2455
- // 8. Calcul du nouveau hash et préparation des données finales
2456
- const finalStateForHash = { ...existingDocs[0], ...updateData };
2457
- const newHash = getFieldValueHash(model, finalStateForHash);
2458
-
2459
- const finalDataForSet = {
2460
- ...updateData,
2461
- _hash: newHash
2462
- };
2463
-
2464
- // 9. *** CORRECTION LOGIQUE ***
2465
- // On ne vérifie l'unicité que si le hash a réellement changé.
2466
- if (newHash !== originalHash) {
2467
- const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
2468
- if (hashCheck) {
2469
- // Le nouvel état du document créerait un doublon.
2470
- throw new Error(i18n.t("api.data.notUniqueData"));
2471
- }
2472
- }
2473
-
2474
- // 10. Exécution de la mise à jour (inchangé)
2475
- const bulkOps = [{ updateMany: { filter: {_id: {$in: ids}}, update: {$set: finalDataForSet} } }];
2476
- const bulkResult = await collection.bulkWrite(bulkOps);
2477
- const modifiedCount = bulkResult.modifiedCount || 0;
2478
-
2479
- // Déclencher l'événement OnDataEdited avec les états avant/après
2480
- if (modifiedCount > 0) {
2481
- const updatedDocs = await collection.find({ _id: { $in: ids } }).toArray();
2482
- await Event.Trigger("OnDataEdited", "event", "system", engine, {
2483
- modelName,
2484
- user,
2485
- before: existingDocs, // Documents avant la modification
2486
- after: updatedDocs // Documents après la modification
2487
- });
2488
- }
2489
-
2490
- // 11. Tâches post-mise à jour (schedules, workflows) (inchangé)
2491
- if (["workflowTrigger", "alert"].includes(modelName)) {
2492
- await handleScheduledJobs(modelName, existingDocs, collection, finalDataForSet);
2493
- }
2494
-
2495
- if (triggerWorkflow && modifiedCount > 0) {
2496
- const updatedDoc = await collection.findOne({_id: ids[0]});
2497
- if (updatedDoc) {
2498
- const proms = triggerWorkflows(updatedDoc, user, 'DataEdited')
2499
- .catch(err => logger.error("[editData] Workflow trigger error:", err));
2500
- if( waitForWorkflow ){
2501
- await proms;
2502
- }
2503
- }
2504
- }
2505
-
2506
- return {
2507
- success: true,
2508
- modifiedCount
2509
- };
2510
-
2511
- } catch (error) {
2512
- logger.error("Erreur lors de la mise à jour de la ressource :", error);
2513
- return {success: false, error: error.message};
2514
- }
2515
- };
2516
-
2517
- // Fonctions helper
2518
- async function processFileArray(files, currentFiles, user) {
2519
- const newFiles = await Promise.allSettled(
2520
- Object.keys(files).map(f=>files[f]).map(async (file, i) => {
2521
- const oldFile = currentFiles.find(f => f.name === file.name);
2522
- if (oldFile && !file.newFile) return oldFile;
2523
- if (file.guid) return file;
2524
- if (!file.newFile) return Promise.reject();
2525
- return await addFile(files[i], user);
2526
- })
2527
- ).then(results => results.map(r => r.value).filter(Boolean));
2528
-
2529
- // Suppression des anciens fichiers non réutilisés
2530
- await Promise.allSettled(
2531
- currentFiles
2532
- .filter(f => !newFiles.some(nf => nf._id === f._id))
2533
- .map(f => removeFile(f, user))
2534
- );
2535
-
2536
- return newFiles;
2537
- }
2538
-
2539
- async function handleScheduledJobs(modelName, existingDocs, collection, updateData) {
2540
- for (const doc of existingDocs) {
2541
- const jobId = `${modelName}_${doc._id}`;
2542
- const existingJob = schedule.scheduledJobs[jobId];
2543
- if (existingJob) existingJob.cancel();
2544
-
2545
- const updatedDoc = {...doc, ...updateData};
2546
- if (modelName === 'workflowTrigger' && updatedDoc.isActive && updatedDoc.cronExpression) {
2547
- schedule.scheduleJob(jobId, updatedDoc.cronExpression, async () => {
2548
- logger.info(`[Scheduled Job] Cron triggered for job ${jobId}`);
2549
- await runScheduledJobWithDbLock(jobId, async () => {
2550
- await sendSseToUser(updatedDoc._user, {
2551
- type: 'cron_alert',
2552
- triggerId: updatedDoc._id.toString(),
2553
- triggerName: updatedDoc.name,
2554
- timestamp: new Date().toISOString(),
2555
- message: `L'alerte planifiée '${updatedDoc.name || 'Sans nom'}' a été déclenchée.`
2556
- });
2557
- }, updatedDoc.lockDurationMinutes || 5);
2558
- });
2559
- }
2560
-
2561
- if (modelName === 'alert' && updatedDoc.isActive && updatedDoc.frequency) {
2562
- await collection.updateOne({_id: updatedDoc._id}, {$set: {lastNotifiedAt: null}});
2563
- schedule.scheduleJob(jobId, updatedDoc.frequency, () => runStatefulAlertJob(updatedDoc._id));
2564
- }
2565
- }
2566
- }
2567
-
2568
-
2569
- export const deleteData = async (modelName, filter, user ={}, triggerWorkflow, waitForWorkflow = false) => {
2570
-
2571
- try {
2572
- const collection = await getCollectionForUser(user);
2573
-
2574
- // --- Début de la logique de suppression ---
2575
-
2576
- // 1. Construire le filtre de base pour trouver les documents à supprimer
2577
- let findFilter = [];
2578
- if(user)
2579
- findFilter.push({
2580
- '$eq': ["$_user", user.username]
2581
- });
2582
-
2583
- // Ajouter le filtre par IDs si fourni
2584
- if (Array.isArray(filter) && filter.length > 0) {
2585
- findFilter.push({"$in": ["$_id", filter.map(m => new ObjectId(m))]});
2586
- }
2587
-
2588
- // Ajouter le filtre par nom de modèle si fourni (utile si 'filter' est utilisé seul)
2589
- if (modelName)
2590
- findFilter.push({
2591
- '$eq': ["$_model", modelName]
2592
- });
2593
-
2594
- // Ajouter le filtre supplémentaire si fourni
2595
- if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
2596
- // Fusionner prudemment le filtre supplémentaire
2597
- // Attention: Si 'filter' contient des clés comme _id ou _user,
2598
- // cela pourrait entrer en conflit. Une fusion plus robuste pourrait re nécessaire.
2599
- findFilter.push(filter);
2600
- }else{
2601
-
2602
- }
2603
-
2604
- // 2. Récupérer les documents à supprimer pour vérifier leur type et annuler les schedules
2605
- const documentsToDelete = await collection.aggregate([{ $match: { $expr: { "$and": findFilter } } }]).toArray();
2606
-
2607
- if (documentsToDelete.length === 0) {
2608
- logger.info(`[deleteData] No documents found matching the criteria for user ${user?.username}.`);
2609
- return ({ success: true, deletedCount: 0, message: "No documents found to delete." });
2610
- }
2611
-
2612
- const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
2613
-
2614
- for (const docToDelete of documentsToDelete) {
2615
- const deletePromises = [];
2616
- const model = await getModel(docToDelete._model, user);
2617
- for (const f of model.fields) {
2618
- const fieldValue = docToDelete[f.name]; // Valeur du champ actuel depuis le document
2619
- if (f.type === 'file') {
2620
- if (typeof fieldValue === 'string' && fieldValue) { // fieldValue est un GUID
2621
- deletePromises.push(removeFile(fieldValue, user));
2622
- }
2623
- } else if (f.type === 'array' && f.itemsType === 'file') {
2624
- if (Array.isArray(fieldValue)) {
2625
- for (const guidInArray of fieldValue) {
2626
- if (typeof guidInArray === 'string' && guidInArray) {
2627
- deletePromises.push(removeFile(guidInArray, user));
2628
- }
2629
- }
2630
- }
2631
- }
2632
- }
2633
- if (deletePromises.length > 0) {
2634
- await Promise.allSettled(deletePromises);
2635
- }
2636
-
2637
- // Vérification des permissions (pour chaque document trouvé)
2638
- if (user?.username !== 'demo' && isLocalUser(user) && (
2639
- !await hasPermission(["API_ADMIN", "API_DELETE_DATA", "API_DELETE_DATA_" + docToDelete._model], user) ||
2640
- await hasPermission(["API_DELETE_DATA_NOT_" + docToDelete._model], user))) {
2641
- // Si l'utilisateur n'a pas la permission pour CE document spécifique, on l'ignore
2642
- logger.warn(`[deleteData] User ${user.username} lacks permission to delete document ${docToDelete._id} of model ${docToDelete._model}. Skipping.`);
2643
- continue; // Passe au document suivant
2644
- }
2645
-
2646
- // *** Ajout de l'annulation du schedule pour workflowTrigger ***
2647
- if (docToDelete._model === 'workflowTrigger') {
2648
- const jobId = `workflowTrigger_${docToDelete._id}`;
2649
- const scheduledJob = schedule.scheduledJobs[jobId];
2650
- if (scheduledJob) {
2651
- scheduledJob.cancel();
2652
- logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted workflowTrigger ${docToDelete._id}.`);
2653
- } else {
2654
- // Ce n'est pas forcément une erreur si le trigger n'avait pas de cronExpression
2655
- logger.debug(`[deleteData] No scheduled job found with ID ${jobId} to cancel for workflowTrigger ${docToDelete._id}.`);
2656
- }
2657
- }
2658
- else if (docToDelete._model === 'alert') {
2659
- const jobId = `alert_${docToDelete._id}`;
2660
- const scheduledJob = schedule.scheduledJobs[jobId];
2661
- if (scheduledJob) {
2662
- scheduledJob.cancel();
2663
- logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted alert ${docToDelete._id}.`);
2664
- }
2665
- }
2666
- // *** Fin de l'ajout ***
2667
-
2668
- if( user ){
2669
- // --- Logique existante pour gérer les relations ---
2670
- const relatedModels = await modelsCollection.aggregate([
2671
- {
2672
- $match: {
2673
- $and: [
2674
- {"fields.relation": {$eq: docToDelete._model}}, // Utilise le modèle du document actuel
2675
- {
2676
- $and: [
2677
- {_user: {$exists: true}},
2678
- { $or: [
2679
- {_user: {$eq:user._user}},
2680
- {_user: {$eq:user.username}}
2681
- ]}
2682
- ]
2683
- }
2684
- ]
2685
- }
2686
- }
2687
- ]).toArray();
2688
-
2689
- for (const relatedModel of relatedModels) {
2690
- let relsSet = {}, pullOps = {}, filterConditions = [];
2691
- relatedModel.fields.forEach(f => {
2692
- if (f.type === 'relation' && f.relation === docToDelete._model) {
2693
- const fieldCondition = { [f.name]: docToDelete._id.toString() };
2694
- filterConditions.push(fieldCondition); // Condition pour trouver les documents liés
2695
-
2696
- if (f.multiple) {
2697
- if (!pullOps.$pull) pullOps.$pull = {};
2698
- pullOps.$pull[f.name] = docToDelete._id.toString();
2699
- } else {
2700
- relsSet[f.name] = null;
2701
- }
2702
- }
2703
- });
2704
-
2705
- if (filterConditions.length > 0) {
2706
- const updateFilter = {
2707
- $and: [
2708
- { _user: { $exists: true } },
2709
- { _model: relatedModel.name },
2710
- { $or: [{ _user: user._user }, { _user: user.username }] },
2711
- { $or: filterConditions }
2712
- ]
2713
- };
2714
-
2715
- const updateOps = {};
2716
- if (Object.keys(relsSet).length > 0) updateOps.$set = relsSet;
2717
- if (pullOps.$pull && Object.keys(pullOps.$pull).length > 0) updateOps.$pull = pullOps.$pull;
2718
-
2719
- if (Object.keys(updateOps).length > 0) {
2720
- const elementsToUpdate = await collection.find(updateFilter).toArray();
2721
- const updateResult = await collection.updateMany(updateFilter, updateOps);
2722
- logger.debug(`[deleteData] Updated relations in model ${relatedModel.name} referencing ${docToDelete._id}. Modified: ${updateResult.modifiedCount}`);
2723
-
2724
- if( triggerWorkflow ) {
2725
- elementsToUpdate.forEach(e => {
2726
- collection.findOne({_id: e._id}).then(updatedDoc => {
2727
- if (updatedDoc) {
2728
- triggerWorkflows(updatedDoc, user, 'DataEdited').catch(workflowError => {
2729
- logger.error(`[deleteData] Async error triggering DataEdited workflow for ${updatedDoc._model} ID ${updatedDoc._id}:`, workflowError);
2730
- });
2731
- }
2732
- });
2733
- });
2734
- }
2735
- }
2736
- }
2737
- }
2738
-
2739
- // --- Fin de la logique des relations ---
2740
-
2741
- // Déclencher le workflow DataDeleted AVANT la suppression effective
2742
- if( triggerWorkflow)
2743
- {
2744
- const prom = triggerWorkflows(docToDelete, user, 'DataDeleted').catch(workflowError => {
2745
- logger.error(`[deleteData] Async error triggering DataDeleted workflow for ${docToDelete._model} ID ${docToDelete._id}:`, workflowError);
2746
- });
2747
-
2748
- if( waitForWorkflow){
2749
- await prom;
2750
- }
2751
- }
2752
- }
2753
-
2754
- // Ajouter l'ID à la liste finale si la permission est accordée
2755
- finalIdsToDelete.push(docToDelete._id);
2756
-
2757
- } // Fin de la boucle sur documentsToDelete
2758
-
2759
- // 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
2760
- let deletedCount = 0;
2761
- if (finalIdsToDelete.length > 0) {
2762
- const result = await collection.deleteMany({
2763
- _id: { $in: finalIdsToDelete }
2764
- // Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
2765
- });
2766
- deletedCount = result.deletedCount;
2767
- logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
2768
- } else {
2769
- logger.info(`[deleteData] No documents to delete for user ${user?.username} after permission checks or matching criteria.`);
2770
- }
2771
-
2772
- const res = { success: true, deletedCount }
2773
- const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, {model:modelName, filter});
2774
- await Event.Trigger("OnDataDeleted", "event", "user", {model:modelName, filter});
2775
- return plugin || res;
2776
-
2777
- } catch (error) {
2778
- logger.error(`[deleteData] Error during deletion process for user ${user?.username}:`, error);
2779
- // Renvoyer une structure d'erreur cohérente
2780
- return ({ success: false, error: error.message || "An unexpected error occurred during deletion.", statusCode: error.statusCode || 500 });
2781
- }
2782
- }
2783
-
2784
- export const searchData = async (query, user) => {
2785
- const { page, limit, sort, model, ids, timeout, pack } = query; // Les filtres de la requête (attention aux injections MongoDB !)
2786
-
2787
- if( user && user.username !== 'demo' && isLocalUser(user) && (
2788
- !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+model], user) ||
2789
- await hasPermission(["API_SEARCH_DATA_NOT_"+model], user))){
2790
- throw new Error(i18n.t('api.permission.searchData'));
2791
- }
2792
-
2793
- const collection = await getCollectionForUser(user);
2794
- const modelElement = await getModel(model, user);
2795
-
2796
- const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
2797
- return new ObjectId(m);
2798
- });
2799
- let l = Math.min(modelElement.maxRequestData || maxRequestData, limit ? parseInt(limit, 10) : maxRequestData);
2800
- let p = parseInt(page, 10);
2801
- let filter = query.filter || {};
2802
-
2803
- let sortObj = {};
2804
- sort?.split(',').forEach(s => {
2805
- const v = s.split(':');
2806
- sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
2807
- })
2808
- if( !sort ){
2809
- sortObj = {[modelElement.fields[0]?.name || '_id']: ['datetime','date'].includes(modelElement.fields[0].type) ? -1 : 1};
2810
- }
2811
-
2812
- let i = 0;
2813
- const f = {...filter};
2814
-
2815
- let depthParam = Math.max(1, Math.min(maxFilterDepth, typeof(query.depth) === 'string' ? parseInt(query.depth) : (typeof(query.depth) === 'number' ? query.depth : 1)));
2816
- let autoExpand = typeof(query.autoExpand)==='undefined' || (typeof(query.autoExpand)==='string' && ['1','true'].includes(query.autoExpand.toLowerCase()));
2817
-
2818
- const recursiveLookup = async (model, data, depth= 1, already = [], parentPath = '') => {
2819
-
2820
- if( depth > depthParam )
2821
- return [];
2822
-
2823
- let pipelines = [], pipelinesLookups = [];
2824
- let modelElement;
2825
- try {
2826
- modelElement = await getModel(model, user);
2827
- } catch (e) {
2828
- return [];
2829
- }
2830
-
2831
- let dataRelationF= [], dataNoRelation = {};
2832
- let dte= changeValue(data, '$find', (name, d, topLevel) => {
2833
- if (autoExpand)
2834
- depthParam++;
2835
- const field = modelElement.fields.find(f => f.name === name);
2836
- if( !field || !name )
2837
- return {};
2838
- if( field.type === "relation") {
2839
- const dt = {
2840
- '$ne': [
2841
- {
2842
- '$filter': {
2843
- 'input': (depth === 1 ? "$" + name : "$this." + name),
2844
- 'as': 'this',
2845
- 'cond': d
2846
- }
2847
- }
2848
- , []]
2849
- };
2850
- dataRelationF.push(dt);
2851
- dataRelationF.push({ "$ne": [(depth===1 ? "$" + name : "$this." + name), null] })
2852
- return {"$internal": {}};
2853
- }
2854
- dataNoRelation[name] = d;
2855
- return {"$internal": d};
2856
- });
2857
-
2858
- dataNoRelation = changeValue(dte, '$internal', (name, d, topLevel) =>{
2859
- return d;
2860
- });
2861
-
2862
- for (let fi of modelElement.fields.sort((s1,s2)=>{
2863
- const v = s1.type ==='relation' ? 1 :0;
2864
- const v2 = s2.type ==='relation' ? 1 : 0;
2865
-
2866
- const t1 = s1.type === 'calculated' ? 1 : 0;
2867
- const t2 = s2.type === 'calculated' ? 1 : 0;
2868
- return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
2869
- } )) {
2870
-
2871
- // **Circular Reference Check:**
2872
- if (already.includes(fi.relation)) {
2873
- // Skip the lookup if we've already processed this relation in the current chain.
2874
- console.warn(`Skipping circular reference to model: ${fi.relation}`);
2875
- continue;
2876
- }
2877
- const relSort = {};
2878
- if (fi.type === 'relation' && depthParam !== 1) {
2879
- delete f[fi.name];
2880
- if (sortObj[fi.name]) {
2881
-
2882
- const sortColumn = await getModel(fi.relation, user);
2883
- let t = sortColumn.fields.find(f => f.asMain)?.name;
2884
- if (!t) {
2885
- let t = sortColumn.fields?.find((f) => f.type === 'string_t')?.name;
2886
- if (t)
2887
- relSort['items' + i + '.' + t] = sortObj[fi.name];
2888
- else {
2889
-
2890
- t = sortColumn.fields?.find((f) => f.type === 'string')?.name;
2891
- if (t) {
2892
- relSort['items' + i + '.' + t] = sortObj[fi.name];
2893
- }
2894
- }
2895
- } else {
2896
- relSort['items' + i + '.' + t] = sortObj[fi.name];
2897
- }
2898
- if (t) {
2899
- delete sortObj[fi.name];
2900
- }
2901
- }
2902
-
2903
- // Création du lookup si l'expand est activé
2904
- ++i;
2905
- const lookup = {
2906
- $lookup: {
2907
- from: await getUserCollectionName(user),
2908
- as: "items" + i,
2909
- let: {
2910
- dtid: {'$toString': '$_id'}, convertedId: '$' + fi.name
2911
- },
2912
- pipeline: [
2913
- {
2914
- $match: {
2915
- $expr: {
2916
- $and: [
2917
- ...[
2918
- {$eq: ["$_model", fi.relation]},
2919
- {$eq: ["$_user", user.username]},
2920
- fi.multiple ? {
2921
- $in: [{ $toString: "$_id" }, {
2922
- $map: {
2923
- input: { $ifNull: ["$$convertedId", []] }, // On utilise le tableau d'IDs, ou un tableau vide s'il est null
2924
- as: "relationId",
2925
- in: { $toString: "$$relationId" }
2926
- }
2927
- }]
2928
- } : {
2929
- $eq: [
2930
- { $toString: "$_id" },
2931
- { $convert: { input: '$$convertedId', to: "string", onError: '' } }
2932
- ]
2933
- }
2934
- ]
2935
- ]
2936
- }
2937
- }
2938
- },
2939
- {$limit: maxRelationsPerData}
2940
- ]
2941
- }
2942
- };
2943
-
2944
- pipelinesLookups.push(lookup);
2945
- pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
2946
-
2947
- // Construct the path for the current field
2948
- const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
2949
- fi.path = currentPath;
2950
- pipelinesLookups.push(
2951
- {
2952
- $addFields: {
2953
- [`${fi.name}`]: (fi.multiple ? "$items" + i : {
2954
- $cond: {
2955
- if: {$gt: [{$size: {$ifNull: ["$items" + i, []]}}, 0]},
2956
- then: "$items" + i,
2957
- else: null
2958
- }
2959
- })
2960
- }
2961
- }
2962
- );
2963
-
2964
- //found = true;
2965
- pipelinesLookups.push(
2966
- {$project: {['items' + i]: 0}}
2967
- );
2968
-
2969
- const nextAlready = [...already, fi.relation];
2970
- if (Object.keys(relSort).length) {
2971
- pipelinesLookups.push(
2972
- {$sort: relSort}
2973
- );
2974
- }
2975
-
2976
- let find = dte[fi.name] || [];
2977
-
2978
- const rec = await recursiveLookup(fi.relation, find, depth + 1, nextAlready, currentPath);
2979
-
2980
- if (rec.length) {
2981
- lookup['$lookup']['pipeline'] = lookup['$lookup']['pipeline'].concat(rec);
2982
- }
2983
-
2984
- lookup['$lookup']['pipeline'].push(
2985
- {$project: {_model: 0}}
2986
- );
2987
- lookup['$lookup']['pipeline'].push(
2988
- {$project: {_user: 0}}
2989
- );
2990
-
2991
- }else if (fi.type === 'file') {
2992
- // Logique pour enrichir un champ fichier unique
2993
-
2994
- // Stage 1: Lookup file details from the 'files' collection
2995
- pipelinesLookups.push({
2996
- $lookup: {
2997
- from: "files", // The global collection where file metadata is stored
2998
- let: { fileGuid: '$' + fi.name }, // The GUID string from the current document's field
2999
- pipeline: [
3000
- {
3001
- $match: {
3002
- $expr: {
3003
- $and: [
3004
- { $eq: ['$guid', '$$fileGuid'] },
3005
- { $eq: ['$user', user.username] }
3006
- ]
3007
- }
3008
- }
3009
- },
3010
- { $limit: 1 } // GUIDs should be unique, so limit to 1
3011
- ],
3012
- as: fi.name + "_details_temp" // Temporary field to store the lookup result (an array)
3013
- }
3014
- });
3015
-
3016
- // Stage 2: Replace the original GUID string with the fetched file object (or null if not found)
3017
- pipelinesLookups.push({
3018
- $addFields: {
3019
- [fi.name]: {
3020
- // $lookup returns an array, take the first element.
3021
- // If lookup result is empty or null, set the field to null.
3022
- $ifNull: [ { $first: '$' + fi.name + "_details_temp" }, null ]
3023
- }
3024
- }
3025
- });
3026
-
3027
- // Stage 3: Clean up the temporary lookup field
3028
- pipelinesLookups.push({
3029
- $project: {
3030
- [fi.name + "_details_temp"]: 0
3031
- }
3032
- });
3033
- } else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
3034
- // This field (e.g., 'myImageGallery') stores an array of GUID strings: ["guid1", "guid2"]
3035
- pipelinesLookups.push(
3036
- {
3037
- $lookup: {
3038
- from: "files", // The global collection where file metadata is stored
3039
- let: { localGuidsArray: '$' + fi.name }, // The array of GUID strings from the current document
3040
- pipeline: [
3041
- {
3042
- $match: {
3043
- $expr: {
3044
- // Match documents in "files" collection where 'guid' is in the '$$localGuidsArray'
3045
- $in: ['$guid', { $ifNull: ['$$localGuidsArray', []] }] // Handle null or missing array
3046
- }
3047
- }
3048
- }
3049
- // Optional: Project only necessary fields from the "files" collection if needed
3050
- // {
3051
- // $project: {
3052
- // _id: 0, // Exclude MongoDB's _id from the 'files' collection documents
3053
- // // mainUser: 0, // Example: if you don't need these in the result
3054
- // // user: 0,
3055
- // // _model:0, // The _model "privateFile" might not be useful here
3056
- // // Keep: guid, filename (as name), mimetype, size, timestamp etc.
3057
- // }
3058
- // }
3059
- ],
3060
- as: fi.name + "_details_temp" // Temporary field to store the array of matched file detail objects
3061
- }
3062
- },
3063
- // The following $addFields and $project stages are what you had
3064
- // in your fi.type === 'file' block, and they are correct for this array scenario.
3065
- {
3066
- $addFields: {
3067
- [fi.name]: { // Remplacer le tableau de chaînes GUID par un tableau d'objets fichiers détaillés
3068
- $ifNull: [ // Gérer le cas où le champ fi.name est null (original array of GUIDs)
3069
- {
3070
- $map: {
3071
- input: '$' + fi.name, // Itérer sur le tableau original de chaînes GUID
3072
- as: "originalGuidString", // Each element from the input array (a GUID string)
3073
- in: {
3074
- $let: {
3075
- vars: {
3076
- // Trouver le détail correspondant dans _details_temp par GUID
3077
- matchedDetail: {
3078
- $arrayElemAt: [
3079
- {
3080
- $filter: {
3081
- input: '$' + fi.name + "_details_temp", // Use the result from the $lookup above
3082
- as: "detailFile", // Each document from _details_temp
3083
- cond: { $eq: ["$$detailFile.guid", "$$originalGuidString"] }
3084
- }
3085
- },
3086
- 0 // Take the first match (GUIDs should be unique in "files")
3087
- ]
3088
- }
3089
- },
3090
- in: {
3091
- $cond: {
3092
- if: '$$matchedDetail', // Si des détails ont été trouvés
3093
- then: '$$matchedDetail', // Utiliser l'objet détaillé complet
3094
- else: { // Si aucun détail trouvé pour ce GUID (e.g., broken reference)
3095
- guid: '$$originalGuidString', // Conserver le GUID original
3096
- name: null, // Ou une valeur par défaut comme "Fichier inconnu"
3097
- _error: "File details not found"
3098
- // Ou simplement: '$$originalGuidString' si vous voulez juste garder la chaîne
3099
- }
3100
- }
3101
- }
3102
- }
3103
- }
3104
- }
3105
- },
3106
- [] // Si le champ original fi.name était null, le résultat est un tableau vide
3107
- ]
3108
- }
3109
- }
3110
- },
3111
- {
3112
- $project: { // Nettoyer le champ temporaire
3113
- [fi.name + "_details_temp"]: 0
3114
- }
3115
- }
3116
- );
3117
- } else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
3118
- const calcPipelineAbstract = fi.calculation.pipeline;
3119
- const calcFinalFieldName = fi.calculation.final;
3120
- const tempLookupsForThisCalcField = []; // Pour stocker les noms 'as' des lookups de CE champ calculé
3121
-
3122
- // Ajouter les étapes $lookup définies par le calcul
3123
- if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
3124
- for (const lookupDef of calcPipelineAbstract.lookups) {
3125
- // ... (votre logique existante de vérification de foreignModel et localField) ...
3126
- // Assurez-vous que cette logique est robuste comme discuté précédemment.
3127
- // Si une erreur se produit ici (foreignModel non trouvé, etc.),
3128
- // vous ajoutez déjà un $addFields pour initialiser lookupDef.as à null/[]
3129
- // et vous faites 'continue'. C'est bien.
3130
-
3131
- // Si tout va bien, on construit le lookup :
3132
- const targetCollectionName = await getUserCollectionName(user);
3133
- const localFieldValueInPipeline = `$${lookupDef.localField}`;
3134
-
3135
- // Vérification basique du localField (déjà présente dans votre code précédent)
3136
- if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
3137
- logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
3138
- pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
3139
- continue;
3140
- }
3141
-
3142
- const mongoLookupStage = {
3143
- $lookup: {
3144
- from: targetCollectionName,
3145
- let: {local_val: localFieldValueInPipeline},
3146
- pipeline: [
3147
- {
3148
- $match: {
3149
- $expr: {
3150
- $and: [
3151
- {$eq: ["$_model", lookupDef.foreignModel]},
3152
- {$eq: ["$_user", user.username]},
3153
- lookupDef.isMultiple
3154
- ? {$in: [{$toString: "$_id"}, {$ifNull: ["$$local_val", []]}]}
3155
- : {$eq: [{$toString: "$_id"}, {$ifNull: ["$$local_val", null]}]}
3156
- ]
3157
- }
3158
- }
3159
- }
3160
- // Optionnel: Projeter uniquement les champs nécessaires
3161
- ],
3162
- as: lookupDef.as
3163
- }
3164
- };
3165
- pipelinesLookups.push(mongoLookupStage);
3166
- tempLookupsForThisCalcField.push(lookupDef.as); // Suivre ce champ temporaire
3167
-
3168
- if (!lookupDef.isMultiple) {
3169
- pipelinesLookups.push({
3170
- $unwind: {
3171
- path: `$${lookupDef.as}`,
3172
- preserveNullAndEmptyArrays: true
3173
- }
3174
- });
3175
- }
3176
- }
3177
- }
3178
-
3179
- // Ajouter l'étape $addFields pour les calculs eux-mêmes
3180
- if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
3181
- const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({ $addFields: { [m] : calcPipelineAbstract.addFields[m] } }));
3182
- pipelinesLookups = pipelinesLookups.concat(addFields);
3183
- }
3184
-
3185
- // S'assurer que le champ final du calcul (calcFinalFieldName) est bien accessible
3186
- // sous le nom du champ du modèle (fi.name).
3187
- if (calcFinalFieldName !== fi.name) {
3188
- pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
3189
- }
3190
-
3191
- // --- NOUVEAU : Supprimer les champs de lookup temporaires pour CE champ calculé ---
3192
- if (tempLookupsForThisCalcField.length > 0) {
3193
- const unsetProjection = {};
3194
- for (const tempField of tempLookupsForThisCalcField) {
3195
- // On peut supprimer tous les champs __calc_lookup_... car le CalculationBuilder
3196
- // empêche que outputAlias (et donc calcFinalFieldName, et donc fi.name)
3197
- // soit un de ces champs temporaires.
3198
- unsetProjection[tempField] = 0; // 0 signifie supprimer/exclure le champ
3199
- }
3200
- if (Object.keys(unsetProjection).length > 0) {
3201
- pipelinesLookups.push({$project: unsetProjection});
3202
- }
3203
- }
3204
- } else if (fi.type === 'array') {
3205
- // Handle array filtering here
3206
- if (data[fi.name]) {
3207
- pipelines.push({
3208
- $match: {
3209
- $expr: {
3210
- $in: [data[fi.name], '$' + fi.name] // Check if the array contains the value
3211
- }
3212
- }
3213
- });
3214
- }
3215
- } else if (data[fi.name]) {
3216
- if (typeof (data[fi.name]) === 'string') {
3217
- pipelines.push({
3218
- $match: {
3219
- $expr: {
3220
- $eq: ['$' + fi.name, data[fi.name]]
3221
- }
3222
- }
3223
- })
3224
- }
3225
- }
3226
- }
3227
-
3228
-
3229
- let addFields=[];
3230
- /*modelElement.fields.forEach(field => {
3231
- if( field.type==='relation' && !field.multiple && depthParam !== 1 && (dataRelationF.length)){
3232
- addFields.push(
3233
- {$addFields: {[`${field.name}`]: {$first: '$'+field.name }}}
3234
- )
3235
- }
3236
- })*/
3237
- return pipelines.concat([{$match: { '_pack': pack ? pack : { $exists: false }}}, {$match:{$expr:dataNoRelation}}], pipelinesLookups, addFields, [{$match:{$expr:{$and:dataRelationF}}}]);
3238
- };
3239
-
3240
- let pipelines = [];
3241
- if( allIds.length ){
3242
- const id = {$in: ["$_id", allIds.map(m => new ObjectId(m))]};
3243
- pipelines.push({
3244
- $match: { $expr: id }
3245
- });
3246
-
3247
- }else {
3248
-
3249
- pipelines.push(
3250
- {
3251
- $match: {
3252
- $expr: {
3253
- $and: [{$eq: ["$_model", modelElement.name]},
3254
- {$eq: ["$_user", user.username]}]
3255
- }
3256
- }
3257
- }
3258
- )
3259
- }
3260
-
3261
- pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, [])) ;
3262
- if( depthParam ) {
3263
- pipelines.push({$project: {_user: 0}});
3264
- pipelines.push({$project: {_model: 0}});
3265
- }
3266
-
3267
- //console.log(util.inspect(pipelines, false, 29, true));
3268
-
3269
- // 4. Exécuter la pipeline
3270
- const ts = parseInt(timeout, 10)/2.0 || searchRequestTimeout;
3271
- const count = await collection.aggregate([...pipelines, { $count: "count" }]).maxTimeMS(ts).toArray();
3272
- let prom = collection.aggregate(pipelines).maxTimeMS(ts);
3273
-
3274
- if( Object.keys(sortObj).length > 0 ) {
3275
- prom.sort(sortObj);
3276
- }
3277
- prom.skip(p ? (p-1) * l : 0).limit(l);
3278
- let data = await prom.toArray();
3279
- data = await handleFields(modelElement, data, user);
3280
-
3281
- const res = {data, count: count[0]?.count || 0};
3282
- const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
3283
- await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
3284
- return plugin || res;
3285
- }
3286
-
3287
- export const importData = async(options, files, user) => {
3288
-
3289
- if( !(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)){
3290
- return ({ success: false, error: "API_IMPORT_DATA permission needed." });
3291
- }
3292
-
3293
- const file = files.file;
3294
- const hasHeaders = options.hasHeaders ? options.hasHeaders === 'true' : true;
3295
- const csvHeadersString = options.csvHeaders;
3296
-
3297
- if (!file) {
3298
- return ({ success: false, error: "No file uploaded." });
3299
- }
3300
-
3301
- const importJobId = new ObjectId().toString();
3302
- importJobs[importJobId] = {
3303
- userId: user.username,
3304
- status: 'pending',
3305
- totalRecords: 0,
3306
- processedRecords: 0,
3307
- errors: [],
3308
- jobId: importJobId // Inclure l'ID de la tâche dans son état
3309
- };
3310
-
3311
- const importJob = importJobs[importJobId];
3312
- // Excuter le reste de la logique d'importation en arrière-plan
3313
- (async () => {
3314
- let fileProcessed = false;
3315
- const importResults = { success: true, counts: {}, errors: [] }; // Pour collecter les erreurs internes
3316
-
3317
- try {
3318
- const fileContent = fs.readFileSync(file.path);
3319
- // --- DÉBUT DE LA MODIFICATION ---
3320
- // La variable allProcessedData est maintenant déclarée à l'intérieur de la boucle
3321
- // let allProcessedData = [];
3322
- // let modelNameForImport = '';
3323
- // --- FIN DE LA MODIFICATION ---
3324
- if( !file.name ){
3325
- throw new Error("No file provided.");
3326
- }
3327
- if (file.type === 'application/json' || file.name.endsWith('.json')) {
3328
- fileProcessed = true;
3329
- const jsonData = await runImportExportWorker('parse-json', { fileContent: fileContent.toString() });
3330
-
3331
- // --- Logique d'importation des modèles (inchangée) ---
3332
- if (jsonData.models && Array.isArray(jsonData.models)) {
3333
- logger.info(`[Model Import] Found ${jsonData.models.length} models to import for user ${user.username}.`);
3334
- const userModels = await modelsCollection.find({ _user: user.username }).toArray();
3335
- const userModelNames = userModels.map(m => m.name);
3336
-
3337
- for (const modelToInstall of jsonData.models) {
3338
- try {
3339
- const modelName = modelToInstall.name;
3340
- if (!modelName) {
3341
- throw new Error("Model definition in import file is missing a 'name'.");
3342
- }
3343
-
3344
- if (userModelNames.includes(modelName)) {
3345
- logger.debug(`[Model Import] Skipping '${modelName}': already exists for user.`);
3346
- continue;
3347
- }
3348
-
3349
- const modelData = { ...modelToInstall };
3350
- delete modelData._id;
3351
- modelData._user = user.username;
3352
- modelData.locked = false;
3353
- if (modelData.fields) {
3354
- modelData.fields.forEach(f => {
3355
- delete f._id;
3356
- f.locked = false;
3357
- });
3358
- }
3359
-
3360
- await validateModelStructure(modelData);
3361
- await modelsCollection.insertOne(modelData);
3362
- logger.info(`[Model Import] Successfully imported model '${modelName}' for user ${user.username}.`);
3363
- } catch (e) {
3364
- const errorMsg = `Failed to import model '${modelToInstall.name || 'N/A'}': ${e.message}`;
3365
- logger.error(`[Model Import] ${errorMsg}`);
3366
- importResults.errors.push(errorMsg);
3367
- importResults.success = false;
3368
- }
3369
- }
3370
- }
3371
-
3372
- const dataToProcess = jsonData.data || jsonData;
3373
- const modelsToProcess = [];
3374
-
3375
- if (Array.isArray(dataToProcess)) {
3376
- const modelNameForImport = options.model;
3377
- if (!modelNameForImport) {
3378
- importResults.errors.push("Model name is required in the request body when JSON is an array.");
3379
- importResults.success = false;
3380
- } else {
3381
- modelsToProcess.push({ name: modelNameForImport, data: dataToProcess });
3382
- }
3383
- } else if (typeof dataToProcess === 'object' && dataToProcess !== null) {
3384
- for (const modelKey in dataToProcess) {
3385
- if (modelKey === 'models') continue;
3386
- if (Object.prototype.hasOwnProperty.call(dataToProcess, modelKey)) {
3387
- if (Array.isArray(dataToProcess[modelKey])) {
3388
- modelsToProcess.push({ name: modelKey, data: dataToProcess[modelKey] });
3389
- } else {
3390
- const errorMsg = `Data for model '${modelKey}' in JSON object is not an array. Skipping.`;
3391
- logger.warn(`[Import JSON Object] ${errorMsg}`);
3392
- importResults.errors.push(errorMsg);
3393
- importResults.success = false;
3394
- }
3395
- }
3396
- }
3397
- } else {
3398
- 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.");
3399
- importResults.success = false;
3400
- }
3401
-
3402
- if (modelsToProcess.length === 0 && importResults.errors.length === 0) {
3403
- importResults.errors.push("No data found to process in JSON file.");
3404
- importResults.success = false;
3405
- }
3406
-
3407
- // --- DÉBUT DE LA MODIFICATION PRINCIPALE ---
3408
- // Mettre à jour le statut et le nombre total d'enregistrements avant la boucle
3409
- if (importResults.success && modelsToProcess.length > 0) {
3410
- importJobs[importJobId].totalRecords = modelsToProcess.reduce((acc, model) => acc + model.data.length, 0);
3411
- importJobs[importJobId].status = 'processing';
3412
- }
3413
-
3414
- // Boucler sur CHAQUE modèle trouvé dans le fichier JSON
3415
- for (const modelData of modelsToProcess) {
3416
- const modelName = modelData.name;
3417
- const dataArray = modelData.data;
3418
-
3419
- if (dataArray.length === 0) {
3420
- logger.info(`[Import Job ${importJobId}] Skipping model '${modelName}' as it has no data.`);
3421
- continue;
3422
- }
3423
-
3424
- logger.info(`[Import Job ${importJobId}] Processing ${dataArray.length} records for model '${modelName}'.`);
3425
-
3426
- try {
3427
- const modelDef = await getModel(modelName, user);
3428
- if (!modelDef) {
3429
- throw new Error(i18n.t('api.model.notFound', { model: modelName }));
3430
- }
3431
-
3432
- const allProcessedData = convertDataTypes(dataArray, modelDef.fields, 'json');
3433
-
3434
- // Logique de découpage en lots pour le modèle actuel
3435
- for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
3436
- const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
3437
- try {
3438
- const insertedIdsArray = await pushDataUnsecure(chunk, modelName, user, {});
3439
- if (insertedIdsArray && insertedIdsArray.length > 0) {
3440
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
3441
- logger.debug(`[Import Job ${importJobId}] Processed chunk for '${modelName}': ${insertedIdsArray.length} records. Total processed: ${importJobs[importJobId].processedRecords}`);
3442
- await sendSseToUser(user.username, {
3443
- type: 'import_progress',
3444
- job: importJobs[importJobId]
3445
- });
3446
- }
3447
- } catch (chunkError) {
3448
- console.log(chunkError.stack);
3449
- const errorMsg = `[Import Job ${importJobId}] Error on chunk for model '${modelName}': ${chunkError.message}`;
3450
- logger.error(errorMsg);
3451
- importResults.errors.push(errorMsg);
3452
- importJobs[importJobId].errors.push(errorMsg);
3453
- importResults.success = false;
3454
- }
3455
-
3456
- if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
3457
- await delay(IMPORT_CHUNK_DELAY_MS);
3458
- }
3459
- }
3460
- importResults.counts[modelName] = (importResults.counts[modelName] || 0) + allProcessedData.length;
3461
-
3462
- } catch (modelProcessingError) {
3463
- const errorMsg = `[Import Job ${importJobId}] Failed to process model '${modelName}': ${modelProcessingError.message}`;
3464
- logger.error(errorMsg);
3465
- importResults.errors.push(errorMsg);
3466
- importJobs[importJobId].errors.push(errorMsg);
3467
- importResults.success = false;
3468
- }
3469
- }
3470
- // --- FIN DE LA MODIFICATION PRINCIPALE ---
3471
-
3472
- }
3473
- else if (file.type === 'text/csv' || file.name.endsWith('.csv')) {
3474
- // --- Logique CSV (inchangée, mais maintenant elle est séparée de la logique JSON) ---
3475
- fileProcessed = true;
3476
- const modelNameForImport = options.model;
3477
- if (!modelNameForImport) {
3478
- importResults.errors.push("Model name is required in the request body for CSV import.");
3479
- importResults.success = false;
3480
- } else {
3481
- try {
3482
- const modelDef = await getModel(modelNameForImport, user);
3483
- if (!modelDef) {
3484
- throw new Error(i18n.t('api.model.notFound', { model: modelNameForImport }));
3485
- }
3486
-
3487
- let userDefinedHeadersForMapping = [];
3488
- if (!hasHeaders && csvHeadersString && typeof csvHeadersString === 'string') {
3489
- userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
3490
- }
3491
-
3492
- const records = await runImportExportWorker('parse-csv', {
3493
- fileContent: fileContent.toString(),
3494
- options: { columns: hasHeaders }
3495
- });
3496
-
3497
- let datasToImport;
3498
- if (!hasHeaders) {
3499
- const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
3500
- ? userDefinedHeadersForMapping
3501
- : modelDef.fields.map(f => f.name);
3502
-
3503
- datasToImport = records.map(recordRow => {
3504
- const obj = {};
3505
- if (Array.isArray(recordRow)) {
3506
- recordRow.forEach((value, index) => {
3507
- const targetModelFieldName = effectiveHeadersForMapping[index];
3508
- if (targetModelFieldName && targetModelFieldName !== '') {
3509
- if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
3510
- obj[targetModelFieldName] = value;
3511
- } else {
3512
- logger.warn(`CSV Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
3513
- }
3514
- }
3515
- });
3516
- } else {
3517
- Object.values(recordRow).forEach((value, index) => {
3518
- const targetModelFieldName = effectiveHeadersForMapping[index];
3519
- if (targetModelFieldName && targetModelFieldName !== '') {
3520
- if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
3521
- obj[targetModelFieldName] = value;
3522
- } else {
3523
- logger.warn(`CSV Import (!hasHeaders, object row): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
3524
- }
3525
- }
3526
- });
3527
- }
3528
- return obj;
3529
- });
3530
- } else {
3531
- datasToImport = records;
3532
- }
3533
- const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'csv');
3534
-
3535
- // Logique de découpage en lots pour le CSV
3536
- if (allProcessedData.length > 0) {
3537
- importJobs[importJobId].totalRecords = allProcessedData.length;
3538
- importJobs[importJobId].status = 'processing';
3539
-
3540
- for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
3541
- const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
3542
- try {
3543
- const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
3544
- if (insertedIdsArray && insertedIdsArray.length > 0) {
3545
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
3546
- await sendSseToUser(user.username, {
3547
- type: 'import_progress',
3548
- job: importJobs[importJobId]
3549
- });
3550
- }
3551
- } catch (chunkError) {
3552
- const errorMsg = `[Import Job ${importJobId}] Error on CSV chunk: ${chunkError.message}`;
3553
- logger.error(errorMsg, chunkError.stack);
3554
- importResults.errors.push(errorMsg);
3555
- importJobs[importJobId].errors.push(errorMsg);
3556
- importResults.success = false;
3557
- }
3558
- if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
3559
- await delay(IMPORT_CHUNK_DELAY_MS);
3560
- }
3561
- }
3562
- importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
3563
- }
3564
-
3565
- } catch (modelProcessingError) {
3566
- logger.error(`[Import CSV] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
3567
- importResults.errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
3568
- importResults.success = false;
3569
- importJobs[importJobId].errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
3570
- }
3571
- }
3572
- }
3573
- else if (['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type) || file.name.endsWith('.xls') || file.name.endsWith('.xlsx')) {
3574
- fileProcessed = true;
3575
- const modelNameForImport = options.model;
3576
- if (!modelNameForImport) {
3577
- importResults.errors.push("Model name is required in the request body for Excel import.");
3578
- importResults.success = false;
3579
- } else {
3580
- try {
3581
- const modelDef = await getModel(modelNameForImport, user);
3582
- if (!modelDef) {
3583
- throw new Error(i18n.t('api.model.notFound', { model: modelNameForImport }));
3584
- }
3585
-
3586
- let datasToImport;
3587
- let excelErrors = [];
3588
-
3589
- // Cas 2: Pas d'en-têtes. On lit les lignes brutes et on les mappe.
3590
- const rows = await readXlsxFile(fileContent, { sheet: 1 });
3591
-
3592
- let userDefinedHeadersForMapping = [];
3593
- if (csvHeadersString && typeof csvHeadersString === 'string') {
3594
- userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
3595
- }
3596
-
3597
- const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
3598
- ? userDefinedHeadersForMapping
3599
- : modelDef.fields.map(f => f.name);
3600
-
3601
- datasToImport = rows.map(recordRow => {
3602
- const obj = {};
3603
- if (Array.isArray(recordRow)) {
3604
- recordRow.forEach((value, index) => {
3605
- const targetModelFieldName = effectiveHeadersForMapping[index];
3606
- if (targetModelFieldName && targetModelFieldName !== '') {
3607
- if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
3608
- obj[targetModelFieldName] = value;
3609
- } else {
3610
- logger.warn(`Excel Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
3611
- }
3612
- }
3613
- });
3614
- }
3615
- return obj;
3616
- });
3617
-
3618
- if (excelErrors.length > 0) {
3619
- excelErrors.forEach(error => {
3620
- const errorMsg = `Excel Import Error (Row ${error.row}, Column "${error.column}"): ${error.error}.`;
3621
- logger.error(`[Import Job ${importJobId}] ${errorMsg}`);
3622
- importResults.errors.push(errorMsg);
3623
- importJobs[importJobId].errors.push(errorMsg);
3624
- });
3625
- importResults.success = false;
3626
- }
3627
-
3628
- if (datasToImport && datasToImport.length > 0) {
3629
- const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'excel');
3630
-
3631
- importJobs[importJobId].totalRecords = allProcessedData.length;
3632
- importJobs[importJobId].status = 'processing';
3633
-
3634
- for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
3635
- const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
3636
- try {
3637
- const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
3638
- if (insertedIdsArray && insertedIdsArray.length > 0) {
3639
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
3640
- await sendSseToUser(user.username, {
3641
- type: 'import_progress',
3642
- job: importJobs[importJobId]
3643
- });
3644
- }
3645
- } catch (chunkError) {
3646
- const errorMsg = `[Import Job ${importJobId}] Error on Excel chunk: ${chunkError.message}`;
3647
- logger.error(errorMsg, chunkError.stack);
3648
- importResults.errors.push(errorMsg);
3649
- importJobs[importJobId].errors.push(errorMsg);
3650
- importResults.success = false;
3651
- }
3652
- if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) await delay(IMPORT_CHUNK_DELAY_MS);
3653
- }
3654
- importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
3655
- }
3656
-
3657
- } catch (modelProcessingError) {
3658
- logger.error(`[Import Excel] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
3659
- importResults.errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
3660
- importResults.success = false;
3661
- importJobs[importJobId].errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
3662
- }
3663
- }
3664
- }
3665
- else {
3666
- importResults.errors.push("Unsupported file type. Please upload a JSON or CSV file.");
3667
- importResults.success = false;
3668
- importJobs[importJobId].errors.push("Unsupported file type. Please upload a JSON or CSV file.");
3669
- }
3670
-
3671
- } catch (e) {
3672
- logger.error("Import Error (Global):", e);
3673
- importResults.success = false;
3674
- importResults.errors.push(e.message || "An unexpected error occurred during import.");
3675
- if (importJobs[importJobId]) {
3676
- importJobs[importJobId].errors.push(e.message || "An unexpected error occurred during import.");
3677
- }
3678
- } finally {
3679
- if (importJobs[importJobId]) {
3680
- if (importResults.errors.length > 0) {
3681
- importJobs[importJobId].status = 'failed';
3682
- } else {
3683
- importJobs[importJobId].status = 'completed';
3684
- }
3685
- await sendSseToUser(user.username, {
3686
- type: 'import_progress',
3687
- job: importJobs[importJobId]
3688
- });
3689
- }
3690
- if (file && file.path && fs.existsSync(file.path)) {
3691
- fs.unlinkSync(file.path);
3692
- }
3693
- }
3694
- })().catch(error => {
3695
- logger.error(`Unhandled error in background import job ${importJobId}:`, error);
3696
- if (importJobs[importJobId]) {
3697
- importJobs[importJobId].status = 'failed';
3698
- importJobs[importJobId].errors.push(error.message || "An unhandled error occurred in background process.");
3699
- sendSseToUser(user.username, {
3700
- type: 'import_progress',
3701
- job: importJobs[importJobId]
3702
- });
3703
- }
3704
- });
3705
- return ({success: true, message: "Import initiated. Check progress via SSE.", job: importJob});
3706
- }
3707
-
3708
- export const exportData= async (options, user) =>{
3709
- // Extract parameters from request body and query
3710
- const { models, ids, filter = {}, depth, lang } = options;
3711
- const userId = getUserId(user);
3712
-
3713
- const effectiveMaxDepth = maxExportCount ?? maxFilterDepth; // Use defined constant or fallback
3714
- i18n.changeLanguage(lang);
3715
-
3716
- // --- Input Validation ---
3717
- if (!Array.isArray(models) || models.length === 0) {
3718
- return { success: false, error: i18n.t('api.export.error.noModels', 'Models array is required.') };
3719
- }
3720
- const parsedDepth = parseInt(depth, 10);
3721
- if (isNaN(parsedDepth) || parsedDepth < 0 || parsedDepth > effectiveMaxDepth) {
3722
- return { success: false, error: i18n.t('api.export.error.invalidDepth', `Invalid depth parameter. Must be between 0 and ${effectiveMaxDepth}.`, { maxDepth: effectiveMaxDepth }) };
3723
- }
3724
- if (ids && !Array.isArray(ids)) {
3725
- return { success: false, error: i18n.t('api.export.error.invalidIdsType', 'ids parameter must be an array if provided.') };
3726
- }
3727
- if (ids && !ids.every(id => typeof id === 'string' || isObjectId(id))) { // Allow string or ObjectId format
3728
- return { success: false, error: i18n.t('api.export.error.invalidIdsContent', 'ids parameter must contain valid identifiers (strings or ObjectIds).') };
3729
- }
3730
-
3731
- const exportResults = {};
3732
- let totalDocsFetched = 0;
3733
- const errors = [];
3734
-
3735
- let modelsToExport = [];
3736
-
3737
- // --- Permissions & Data Fetching Loop ---
3738
- for (const modelName of models) {
3739
- if (totalDocsFetched >= maxExportCount) {
3740
- console.warn(`Export limit of ${maxExportCount} documents reached before processing model ${modelName}.`);
3741
- errors.push(i18n.t('api.export.error.limitReached', 'Export document limit reached.', { limit: maxExportCount }));
3742
- break; // Stop fetching more models if limit is hit early
3743
- }
3744
-
3745
- try {
3746
- // Check permission to search/export data for this model
3747
- // Using API_SEARCH_DATA as a proxy, adjust if a specific export permission exists
3748
- // Assuming checkPermission is adapted or hasPermission is used directly
3749
- // Note: Your original code used checkPermission, but data.js has hasPermission. Adjust as needed.
3750
- // Example using hasPermission:
3751
- // if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user)) {
3752
- // Example using checkPermission (if it exists and works similarly):
3753
- if (isLocalUser(user) && !(isDemoUser(user) && Config.Get("useDemoAccounts")) && !(await hasPermission('API_EXPORT_DATA', user))) { // Adapt this line based on your actual permission function
3754
- console.warn(`User ${userId} lacks permission to search/export model ${modelName}`);
3755
- errors.push(i18n.t('api.permission.searchData', 'Cannot search data from the API') + ` (${modelName})`);
3756
- continue; // Skip this model
3757
- }
3758
-
3759
- // Fetch model definition securely (needed by searchData internally anyway)
3760
- // getModel might throw if not found, handle this
3761
- try {
3762
- const mod = await getModel(modelName, user);
3763
- modelsToExport.push(mod);
3764
- } catch (modelError) {
3765
- console.warn(`Model ${modelName} not found or not accessible for user ${userId}: ${modelError.message}`);
3766
- errors.push(i18n.t('api.model.notFound', 'Model {{model}} not found.', { model: modelName }));
3767
- continue; // Skip this model
3768
- }
3769
-
3770
-
3771
-
3772
- // Construct the query filter for the current model
3773
- let modelSpecificFilter = { ... (filter[modelName] || {}) }; // Use model-specific filter from request if provided
3774
- // _user filter is handled internally by searchData based on the user object passed
3775
-
3776
- if( ids && ids.length > 0)
3777
- modelSpecificFilter = { $in : ['$_id', ids]};
3778
-
3779
- // Calculate remaining limit for this model
3780
- const remainingLimit = maxExportCount - totalDocsFetched;
3781
-
3782
- // --- Fetch Data using searchData ---
3783
- const searchParams = {
3784
- model: modelName,
3785
- filter: modelSpecificFilter,
3786
- depth: parsedDepth,
3787
- limit: remainingLimit
3788
- };
3789
-
3790
- const { data: resultData, count } = await searchData(searchParams, user);
3791
-
3792
- if (resultData && resultData.length > 0) {
3793
- exportResults[modelName] = resultData;
3794
- totalDocsFetched += resultData.length;
3795
- logger.debug(`Fetched ${resultData.length} documents for model ${modelName}. Total: ${totalDocsFetched}`);
3796
- } else {
3797
- logger.debug(`No documents found for model ${modelName} with the given criteria.`);
3798
- }
3799
-
3800
- } catch (modelError) {
3801
- // Catch errors specific to processing this model (e.g., from searchData)
3802
- console.error(`Error exporting model ${modelName}:`, modelError);
3803
- errors.push(i18n.t('api.export.error.modelError', 'Error exporting model {{model}}.', { model: modelName }) + ` (${modelError.message})`);
3804
- }
3805
- } // End of loop through models
3806
-
3807
- // --- Prepare and Send Response ---
3808
- if (Object.keys(exportResults).length === 0) {
3809
- const finalError = errors.length > 0 ? errors.join('; ') : i18n.t('api.data.noExportData', 'No data found for the specified criteria or permissions denied.');
3810
- // Use 404 if no data found/accessible, 400 if only errors occurred but no data attempt was possible
3811
- const statusCode = errors.length > 0 && totalDocsFetched === 0 ? 400 : 404;
3812
- return { success: false, error: finalError };
3813
- }
3814
-
3815
- // Include errors in the response if any occurred but some data was fetched
3816
- if (errors.length > 0) {
3817
- exportResults._exportErrors = errors;
3818
- }
3819
-
3820
- const res = { success: true, data: exportResults, models: modelsToExport };
3821
- const plugin = await Event.Trigger("OnDataExported", "event", "system", engine, exportResults, modelsToExport);
3822
- await Event.Trigger("OnDataExported", "event", "user", plugin?.exportResults || exportResults, plugin?.modelsToExport || modelsToExport);
3823
- return plugin || res;
3824
- }
3825
-
3826
- function handleCalculationExpression(calcExpression, fi, modelElement, calculationName) {
3827
- // Check if the calculation expression involves an operator
3828
- if (typeof calcExpression === 'object' && calcExpression !== null && Object.keys(calcExpression).length === 1) {
3829
- const operator = Object.keys(calcExpression)[0];
3830
- const operands = calcExpression[operator];
3831
-
3832
- // Validation of isValidAggregationOperator
3833
- if (!isValidAggregationOperator(operator)) {
3834
- logger.warn(`Invalid aggregation operator '${operator}' in calculation. Skipping.`);
3835
- return null;
3836
- }
3837
-
3838
- // Check Operand Count and Apply $ifNull handling
3839
- if (Array.isArray(operands)) {
3840
- const handledOperands = operands.map(operand => handleOperand(operand, fi, modelElement, calculationName));
3841
- if (handledOperands.some(op => op === null)) { // Skip if any operand is invalid
3842
- return null;
3843
- }
3844
- return { [operator]: handledOperands };
3845
- } else {
3846
- logger.warn(`Invalid operands for operator '${operator}'. Expected an array. Skipping.`);
3847
- return null;
3848
- }
3849
- } else if (typeof calcExpression === 'string' && calcExpression.startsWith('$')) {
3850
- // This is a field reference
3851
- return handleOperand(calcExpression, fi, modelElement, calculationName);
3852
- } else {
3853
- // This is a constant value, return as is.
3854
- return calcExpression;
3855
- }
3856
- }
3857
-
3858
- function handleOperand(operand, fi, modelElement, calculationName) {
3859
- if (typeof operand === 'object' && operand !== null && Object.keys(operand).length === 1) {
3860
- // Nested Calculation: recursively handle
3861
- return handleCalculationExpression(operand, fi, modelElement, calculationName);
3862
- } else if (typeof operand === 'string' && operand.startsWith('$')) {
3863
- // Field Reference: check field existence
3864
- const fieldName = operand.slice(1);
3865
- if (!isValidFieldReference(fieldName, modelElement)) {
3866
- logger.warn(`Invalid field reference '${fieldName}' in calculation. Skipping.`);
3867
- return null;
3868
- }
3869
- return operand;
3870
- } else {
3871
- // Constant Value
3872
- return operand;
3873
- }
3874
- }
3875
-
3876
- function isValidFieldReference(fieldName, modelElement) {
3877
- // Check if the field exists in the model
3878
- return modelElement.fields.some(field => field.name === fieldName);
3879
- }
3880
- function isValidAggregationOperator(operator) {
3881
- const arithmeticOperators = [
3882
- '$add', '$subtract', '$multiply', '$divide', '$mod', '$pow',
3883
- '$abs', '$ceil', '$floor', '$round', '$trunc', '$exp', '$log', '$log10'
3884
- ];
3885
- const comparisonOperators = [
3886
- '$eq', '$gt', '$gte', '$lt', '$lte', '$ne'
3887
- // ... (others like $cmp, $strcasecmp, etc.)
3888
- ];
3889
- const stringOperators = [
3890
- '$concat', '$strLenCP', '$substrCP', '$toLower', '$toUpper'
3891
- // ... (others)
3892
- ];
3893
- const conditionalOperators = ['$cond', '$ifNull'];
3894
- // Add more categories and operators as needed
3895
-
3896
- return [...arithmeticOperators, ...comparisonOperators, ...stringOperators, ...conditionalOperators].includes(operator);
3897
- }
3898
-
3899
- /**
3900
- * Gère les traductions spécifiques à l'utilisateur et traite les données de manière récursive
3901
- * pour l'anonymisation et la résolution des relations.
3902
- *
3903
- * Cette fonction est le point d'entrée principal. Elle charge temporairement les traductions
3904
- * d'un utilisateur, traite les données, puis décharge les traductions pour garantir
3905
- * qu'une requête n'affecte pas les autres.
3906
- *
3907
- * @param {object} model - La définition du modèle pour les données actuelles.
3908
- * @param {object|Array<object>|null} data - Les données à traiter (un objet, un tableau ou null).
3909
- * @param {object} user - L'objet utilisateur effectuant la requête.
3910
- * @param {boolean} [isRecursiveCall=false] - Un drapeau interne pour éviter de recharger les traductions lors des appels récursifs.
3911
- * @returns {Promise<object|Array<object>|null>} - Les données traitées, dans le même format que l'entrée.
3912
- */
3913
- const handleFields = async (model, data, user, isRecursiveCall = false) => {
3914
- // Détermine si l'entrée était un tableau pour retourner le même format.
3915
- const wasArray = Array.isArray(data);
3916
- // Normalise les données en tableau pour un traitement unifié. Gère le cas où data est null/undefined.
3917
- const dataArray = wasArray ? data : (data ? [data] : []);
3918
-
3919
- if (dataArray.length === 0) {
3920
- return wasArray ? [] : null;
3921
- }
3922
-
3923
- // Fonction interne pour traiter les données. Appelée après la gestion des traductions.
3924
- const _processItems = async (items) => {
3925
- const canRead = !isLocalUser(user) || await hasPermission(["API_ADMIN", "API_DEANONYMIZED", "API_DEANONYMIZED_" + model.name], user);
3926
-
3927
- for (const item of items) {
3928
- if (!item) continue;
3929
-
3930
- if( item['_id']){
3931
- item['_id'] = item['_id'].toString();
3932
- }
3933
-
3934
- for (const field of model.fields) {
3935
- const fieldName = field.name;
3936
- const fieldValue = item[fieldName];
3937
-
3938
- if (fieldValue === undefined) continue;
3939
-
3940
- // 1. Anonymisation des champs si nécessaire
3941
- if (field.anonymized && !canRead && dataTypes[field.type]?.anonymize) {
3942
- item[fieldName] = dataTypes[field.type].anonymize(fieldValue, field, getObjectHash({ id: item._id }));
3943
- }
3944
-
3945
- if( field.type === 'string_t'){
3946
- item[fieldName] = { key: fieldValue, value: i18n.t(fieldValue, fieldValue) };
3947
- }
3948
-
3949
- // 2. Résolution récursive des relations
3950
-
3951
- if (field.type === 'relation' && fieldValue) {
3952
- try {
3953
- const relatedModel = await getModel(field.relation, user);
3954
- // Appel récursif à la fonction principale, en signalant que ce n'est pas l'appel initial.
3955
- item[fieldName] = await handleFields(relatedModel, fieldValue, user, true);
3956
- if (!field.multiple && Array.isArray(item[fieldName]) && item[fieldName].length <= 1) {
3957
- item[fieldName] = item[fieldName][0] || null;
3958
- }
3959
- } catch (e) {
3960
- logger.warn(`Impossible de traiter la relation pour le champ '${fieldName}' du modèle '${model.name}'. Erreur: ${e.message}`);
3961
- // En cas d'erreur (ex: modèle de relation introuvable), on conserve la valeur originale (probablement un ID).
3962
- }
3963
- }
3964
- }
3965
- }
3966
- return items;
3967
- };
3968
-
3969
- // Si c'est l'appel initial (non récursif) et qu'un utilisateur est fourni, on gère les traductions.
3970
- if (!isRecursiveCall && user?.username) {
3971
- const lang = user.lang || 'en'; // Utilise la langue définie par le middleware, sinon 'en'.
3972
- let originalTranslations = null;
3973
- let userTranslationsLoaded = false;
3974
-
3975
-
3976
- try {
3977
- const coll = await getCollectionForUser(user);
3978
- // 1. Récupérer l'ID du document de langue de l'utilisateur pour la langue actuelle.
3979
- const userLangDoc = await coll.findOne({
3980
- _model: 'lang',
3981
- code: lang,
3982
- _user: user.username
3983
- });
3984
-
3985
- if (userLangDoc) {
3986
- // 2. Récupérer les traductions de l'utilisateur pour cette langue.
3987
- const userTranslationsArray = await coll.find({
3988
- _model: 'translation',
3989
- _user: user.username,
3990
- lang: userLangDoc._id.toString()
3991
- }).toArray();
3992
-
3993
- if (userTranslationsArray.length > 0) {
3994
- // 3. Préparer le "bundle" de ressources pour i18n.
3995
- const newResourceBundle = userTranslationsArray.reduce((acc, tr) => {
3996
- if (tr.key && tr.value) {
3997
- acc[tr.key] = tr.value;
3998
- }
3999
- return acc;
4000
- }, {});
4001
-
4002
-
4003
- // 4. Charger temporairement les traductions de l'utilisateur.
4004
- if (Object.keys(newResourceBundle).length > 0) {
4005
- // Sauvegarder les traductions originales si elles existent
4006
- if (i18n.store.data[lang] && i18n.store.data[lang].translation) {
4007
- originalTranslations = {...i18n.store.data[lang].translation};
4008
- }
4009
- // Ajoute/remplace les clés de traduction pour la langue et le namespace courants.
4010
- i18n.addResourceBundle(lang, 'translation', newResourceBundle, true, true);
4011
- userTranslationsLoaded = true;
4012
- logger.debug(`Chargement de ${userTranslationsArray.length} traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
4013
- }
4014
- }
4015
- }
4016
-
4017
- // 5. Traiter les données avec les traductions (personnalisées ou par défaut).
4018
- const processedData = await _processItems(dataArray);
4019
- return wasArray ? processedData : processedData[0];
4020
- } finally {
4021
- // 6. Nettoyage : décharger les traductions de l'utilisateur et restaurer les originales.
4022
- // Ce bloc s'exécute toujours, même en cas d'erreur, garantissant l'isolation des requêtes.
4023
- if (userTranslationsLoaded) {
4024
- // Supprime le namespace temporaire.
4025
- i18n.removeResourceBundle(lang, 'translation');
4026
- logger.debug(`Déchargement des traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
4027
-
4028
- // Restaure les traductions originales si elles avaient été sauvegardées.
4029
- if (originalTranslations) {
4030
- i18n.addResourceBundle(lang, 'translation', originalTranslations, true, true);
4031
- logger.debug(`Restauration des traductions originales pour la langue '${lang}'.`);
4032
- }
4033
- }
4034
- }
4035
- } else {
4036
- // C'est un appel récursif ou il n'y a pas d'utilisateur, on traite donc directement les données.
4037
- const processedData = await _processItems(dataArray);
4038
- return wasArray ? processedData : processedData[0];
4039
- }
4040
- };
4041
-
4042
- let restoreRequests = {};
4043
- export const validateRestoreRequest = (username, token) => {
4044
- const request = restoreRequests[username];
4045
- if (!request) {
4046
- return { error: 'Invalid username.' };
4047
- }
4048
-
4049
- if (request.token !== token) {
4050
- return { error: 'Invalid token.' };
4051
- }
4052
-
4053
- if (request.expiresAt < new Date()) {
4054
- delete restoreRequests[username];
4055
- return { error: 'Token has expired.' };
4056
- }
4057
-
4058
- delete restoreRequests[username]; // Remove the request after validation
4059
- return { success: true };
4060
- };
4061
-
4062
-
4063
- export const loadFromDump = async (user, options = {}) => {
4064
- const { modelsOnly = false } = options;
4065
- const action = modelsOnly ? 'restore-models' : 'full-restore';
4066
- logger.info(`[${action}] Starting for user: ${user.username}`);
4067
-
4068
- const encryptedKey = readKeyFromFile(user);
4069
- if (!encryptedKey) {
4070
- throw new Error("No encryption key found for this user. Cannot restore.");
4071
- }
4072
-
4073
- const userId = getObjectHash({ user: user.username });
4074
- const backupDir = getBackupDir();
4075
- let backupFilePath = ''; // Will hold the path to the archive to be restored
4076
- let isTempFile = false; // Flag to know if we need to delete the file later
4077
-
4078
- const tmpRestoreDir = path.join(backupDir, `tmp_restore_${userId}_${Date.now()}`);
4079
-
4080
- try {
4081
- const s3Config = await getUserS3Config(user);
4082
- // --- NEW LOGIC: Check for S3 config first ---
4083
- if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
4084
- logger.info(`[${action}] S3 config found for user. Searching for backups in bucket: ${s3Config.bucketName}`);
4085
-
4086
- const s3Backups = await listS3Backups(s3Config);
4087
- const userBackups = s3Backups
4088
- .filter(f => f.filename.startsWith(`backup_${userId}_`))
4089
- .sort((a, b) => b.timestamp - a.timestamp);
4090
-
4091
- if (userBackups.length === 0) {
4092
- throw new Error(`No S3 backups found for user ${user.username} in bucket ${s3Config.bucketName}.`);
4093
- }
4094
-
4095
- const latestBackup = userBackups[0];
4096
- logger.info(`[${action}] Found latest S3 backup: ${latestBackup.key}. Downloading...`);
4097
-
4098
- // Download the file to a temporary location
4099
- backupFilePath = path.join(backupDir, latestBackup.filename);
4100
- isTempFile = true;
4101
- await downloadFromS3(s3Config, latestBackup.key, backupFilePath);
4102
- logger.info(`[${action}] S3 backup downloaded to ${backupFilePath}.`);
4103
-
4104
- } else {
4105
- // --- FALLBACK LOGIC: Look for local backups ---
4106
- logger.info(`[${action}] No S3 config. Searching for local backups.`);
4107
- const backupFilenameRegex = new RegExp(`^backup_${userId}_(\\d+)\\.tar\\.gz$`);
4108
- const backupFiles = fs.readdirSync(backupDir).filter(filename => backupFilenameRegex.test(filename));
4109
- if (backupFiles.length === 0) {
4110
- throw new Error(`No local backup files found for user ${user.username}.`);
4111
- }
4112
- const latestBackupFile = backupFiles.sort((a, b) => parseInt(b.match(backupFilenameRegex)[1], 10) - parseInt(a.match(backupFilenameRegex)[1], 10))[0];
4113
- backupFilePath = path.join(backupDir, latestBackupFile);
4114
- }
4115
-
4116
- // --- The rest of the logic remains the same, operating on backupFilePath ---
4117
-
4118
- await runCryptoWorkerTask('decrypt', { filePath: backupFilePath, password: encryptedKey });
4119
-
4120
- if (!fs.existsSync(tmpRestoreDir)) {
4121
- fs.mkdirSync(tmpRestoreDir, { recursive: true });
4122
- }
4123
- await tar.extract({ file: backupFilePath, gzip: true, C: tmpRestoreDir, sync: true });
4124
-
4125
- // ... (Cleaning logic: deleteMany, removeFile, cancelAlerts) ...
4126
- const datasCollection = getCollection("datas");
4127
- if (modelsOnly) {
4128
- await modelsCollection.deleteMany({ _user: user.username });
4129
- } else {
4130
- await datasCollection.deleteMany({ _user: user.username });
4131
- await modelsCollection.deleteMany({ _user: user.username });
4132
-
4133
- const filesCollection = getCollection("files");
4134
- const userFiles = await filesCollection.find({ user: user.username, _model: "privateFile" }).toArray();
4135
- for (const file of userFiles) {
4136
- await removeFile(file.guid, user).catch(e => logger.error(e.message));
4137
- }
4138
- await cancelAlerts(user);
4139
- logger.info(`[${action}] Cleaned existing data, models, files, and alerts for user ${user.username}.`);
4140
- }
4141
-
4142
- // --- EXÉCUTION DE MONGORESTORE ---
4143
- const restoreSourceDir = path.join(tmpRestoreDir, dbName);
4144
- if (!fs.existsSync(restoreSourceDir)) {
4145
- throw new Error(`Restore source directory (${restoreSourceDir}) not found.`);
4146
- }
4147
-
4148
- let command;
4149
- const args = [
4150
- '--uri', dbUrl,
4151
- '--db', dbName
4152
- ];
4153
-
4154
- if (modelsOnly) {
4155
- args.push('--nsInclude', `${dbName}.models`);
4156
- } else {
4157
- // mongorestore accepte plusieurs fois l'option --nsInclude
4158
- args.push('--nsInclude', `${dbName}.datas`);
4159
- args.push('--nsInclude', `${dbName}.models`);
4160
- }
4161
- // Le répertoire source est le dernier argument
4162
- args.push(restoreSourceDir);
4163
-
4164
-
4165
- logger.info(`[${action}] Executing restore command: ${command}`);
4166
- await execFileAsync('mongorestore', args);
4167
-
4168
- // ... (Post-restore tasks) ...
4169
- await scheduleAlerts();
4170
- await scheduleWorkflowTriggers();
4171
- modelsCache.flushAll();
4172
-
4173
- logger.info(`[${action}] Restore successful for user ${user.username}.`);
4174
- await Event.Trigger("OnDataRestored", "event", "system");
4175
-
4176
- } finally {
4177
- // --- GUARANTEED CLEANUP ---
4178
- if (fs.existsSync(tmpRestoreDir)) {
4179
- await fs.promises.rm(tmpRestoreDir, { recursive: true, force: true });
4180
- }
4181
-
4182
- // Re-encrypt the original file if it's not a temporary one
4183
- if (fs.existsSync(backupFilePath) && !isTempFile) {
4184
- await runCryptoWorkerTask('encrypt', { filePath: backupFilePath, password: encryptedKey });
4185
- }
4186
-
4187
- // If we downloaded a temp file from S3, delete it
4188
- if (fs.existsSync(backupFilePath) && isTempFile) {
4189
- fs.unlinkSync(backupFilePath);
4190
- logger.info(`[${action}] Deleted temporary downloaded backup file: ${backupFilePath}`);
4191
- }
4192
- }
4193
- };
4194
-
4195
- // Fonction pour générer une clé aléatoire et la stocker dans un fichier
4196
- const generateAndStoreKey = (user) => {
4197
- const backupDir = getBackupDir();
4198
- const keyFile = path.join(backupDir, getObjectHash({id:getUserId(user)})+'_encryption.key');
4199
- const key = crypto.randomBytes(16).toString('hex');
4200
- fs.writeFileSync(keyFile, key, { mode: 0o600 }); // Permissions strictes
4201
- return key;
4202
- };
4203
-
4204
- // Fonction pour lire la clé depuis le fichier
4205
- const readKeyFromFile = (user) => {
4206
- const backupDir = getBackupDir();
4207
- const keyFile = path.join(backupDir, getObjectHash({id:getUserId(user)})+'_encryption.key');
4208
- if (fs.existsSync(keyFile)) {
4209
- return fs.readFileSync(keyFile, 'utf8');
4210
- }
4211
- return null;
4212
- };
4213
-
4214
- export const dumpUserData = async (user) => {
4215
- const s3Config = await getUserS3Config(user);
4216
- const backupDir = getBackupDir();
4217
- const userId = getObjectHash({ user: user.username });
4218
- const backupFilename = `backup_${userId}`;
4219
- const timestamp = Date.now();
4220
-
4221
- // Déclarer les chemins ici pour qu'ils soient accessibles dans tout le scope de la fonction
4222
- const localTempDumpDir = path.join(backupDir, `${backupFilename}_${timestamp}_temp`);
4223
- const finalArchiveName = `${backupFilename}_${timestamp}.tar.gz`;
4224
- const localArchivePath = path.join(backupDir, finalArchiveName);
4225
-
4226
- let encryptedKey = readKeyFromFile(user);
4227
- if (!encryptedKey) {
4228
- encryptedKey = generateAndStoreKey(user);
4229
- }
4230
-
4231
- try {
4232
- const backupFrequency = await engine.userProvider.getBackupFrequency(user);
4233
- logger.info(`Fréquence de sauvegarde : ${backupFrequency}.`);
4234
-
4235
- const collections = await MongoDatabase.listCollections().toArray();
4236
- for (const collection of collections) {
4237
- const collsToBackup = [await getUserCollectionName(user), 'models'];
4238
- if (collsToBackup.includes(collection.name)) {
4239
- const query = { _user: user.username };
4240
- const args = [
4241
- '--uri', dbUrl,
4242
- '--db', dbName,
4243
- '--out', localTempDumpDir,
4244
- '--collection', collection.name,
4245
- '--query', JSON.stringify(query)
4246
- ];
4247
- logger.info(`Exécution de la commande : mongodump ${args.join(' ')}`);
4248
- await execFileAsync('mongodump', args);
4249
- }
4250
- }
4251
- const dumpSourceDir = path.join(localTempDumpDir, dbName);
4252
- if (fs.existsSync(dumpSourceDir)) {
4253
- await tar.create({ gzip: true, file: localArchivePath, C: localTempDumpDir }, [dbName]);
4254
- logger.info(`Archive de sauvegarde locale créée : ${localArchivePath}`);
4255
- } else {
4256
- logger.warn(`Le répertoire de dump ${dumpSourceDir} était vide. Aucune archive n'a créée.`);
4257
- return Promise.resolve();
4258
- }
4259
-
4260
- await runCryptoWorkerTask('encrypt', { filePath: localArchivePath, password: encryptedKey });
4261
-
4262
- try {
4263
- // Attempt the S3 upload
4264
- await uploadToS3(s3Config, localArchivePath, finalArchiveName);
4265
- // ONLY if the upload succeeds, delete the local file.
4266
- fs.unlinkSync(localArchivePath);
4267
- logger.info(`Local archive ${finalArchiveName} deleted after successful S3 upload.`);
4268
- } catch (e) {
4269
- }
4270
-
4271
- logger.info(`Sauvegarde réussie pour l'utilisateur ${user.username}.`);
4272
- await manageBackupRotation(user, await engine.userProvider.getBackupFrequency(user), s3Config);
4273
-
4274
- } catch (error) {
4275
- logger.error(`Erreur lors de la sauvegarde pour l'utilisateur ${user.username}:`, error);
4276
- // Nettoyage de l'archive si elle a été créée avant l'erreur
4277
- if (fs.existsSync(localArchivePath)) {
4278
- fs.unlinkSync(localArchivePath);
4279
- }
4280
- throw error; // Relancer l'erreur pour que l'appelant soit informé
4281
- } finally {
4282
- // --- NETTOYAGE GARANTI ---
4283
- // Ce bloc s'exécute toujours, que la sauvegarde réussisse ou échoue.
4284
- if (fs.existsSync(localTempDumpDir)) {
4285
- fs.rmSync(localTempDumpDir, { recursive: true, force: true });
4286
- logger.info(`Répertoire de dump temporaire ${localTempDumpDir} supprimé.`);
4287
- }
4288
- }
4289
- };
4290
- async function manageBackupRotation(user, backupFrequency, s3Config = null) { // Accepter s3Config
4291
- const userId = getObjectHash({user:user.username});
4292
- let filesToManage = [];
4293
-
4294
- if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
4295
- logger.info(`Gestion de la rotation des sauvegardes S3 pour ${userId}.`);
4296
- const s3Backups = await listS3Backups(s3Config);
4297
- // Filtrer pour ne garder que les backups de cet utilisateur et trier
4298
- filesToManage = s3Backups
4299
- .filter(f => f.filename.startsWith(`backup_${userId}_`) && f.filename.endsWith('.tar.gz'))
4300
- .map(f => ({ name: f.filename, key: f.key, timestamp: f.timestamp })) // listS3Backups devrait fournir le timestamp
4301
- .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant (plus récent en premier)
4302
-
4303
- } else {
4304
- logger.info(`Gestion de la rotation des sauvegardes locales pour ${userId}.`);
4305
- const backupDir = getBackupDir();
4306
- const localFiles = fs.readdirSync(backupDir);
4307
- filesToManage = localFiles
4308
- .filter(f => !fs.lstatSync(path.join(backupDir, f)).isDirectory() && f.startsWith(`backup_${userId}_`) && f.endsWith('.tar.gz'))
4309
- .map(f => {
4310
- const match = f.match(/_(\d+)\.tar\.gz$/);
4311
- return { name: f, key: path.join(backupDir, f), timestamp: match ? parseInt(match[1], 10) : 0 };
4312
- })
4313
- .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant
4314
- }
4315
-
4316
- let maxFilesToKeep;
4317
- // ... (ta logique existante pour maxFilesToKeep basée sur backupFrequency)
4318
- switch (backupFrequency) {
4319
- case 'daily': // Premium
4320
- maxFilesToKeep = 7; // Garder 7 jours
4321
- break;
4322
- case 'weekly': // Standard
4323
- maxFilesToKeep = 4; // Garder 4 semaines
4324
- break;
4325
- case 'monthly': // Free
4326
- default:
4327
- maxFilesToKeep = 2; // Garder 2 mois
4328
- break;
4329
- }
4330
- logger.info(`Rotation pour ${userId}: fréquence ${backupFrequency}, garde ${maxFilesToKeep} sauvegardes.`);
4331
-
4332
-
4333
- if (filesToManage.length > maxFilesToKeep) {
4334
- const filesToDelete = filesToManage.slice(maxFilesToKeep);
4335
- logger.info(`Suppression de ${filesToDelete.length} anciennes sauvegardes pour ${userId}.`);
4336
-
4337
- const deletionPromises = filesToDelete.map(async (fileInfo) => {
4338
- try {
4339
- if (s3Config && s3Config.bucketName) {
4340
- const s3 = new AWS.S3({ /* ... config ... */ accessKeyId: s3Config.accessKeyId, secretAccessKey: s3Config.secretAccessKey, region: s3Config.region });
4341
- await s3.deleteObject({ Bucket: s3Config.bucketName, Key: fileInfo.key }).promise();
4342
- logger.info(`Ancienne sauvegarde S3 supprimée : ${fileInfo.key}`);
4343
- } else {
4344
- await fs.promises.unlink(fileInfo.key); // key est le chemin complet pour les fichiers locaux
4345
- logger.info(`Ancienne sauvegarde locale supprimée : ${fileInfo.name}`);
4346
- }
4347
- } catch (err) {
4348
- logger.error(`Erreur lors de la suppression de l'ancienne sauvegarde ${fileInfo.name || fileInfo.key}:`, err);
4349
- }
4350
- });
4351
- await Promise.allSettled(deletionPromises);
4352
- } else {
4353
- logger.info(`Aucune ancienne sauvegarde à supprimer pour ${userId} (total: ${filesToManage.length}, garde: ${maxFilesToKeep}).`);
4354
- }
4355
- }
4356
-
4357
- /**
4358
- * Installs pack models and data for a user or globally.
4359
- * Can accept either a pack ID (to install from database) or a direct pack JSON object.
4360
- *
4361
- * @param {object} logger - Logger instance
4362
- * @param {string|object} packIdentifier - Either pack ID (string) or pack JSON object
4363
- * @param {object|null} user - User object (if installing for user) or null (for global install)
4364
- * @param {string} [lang='en'] - Language code for localized data
4365
- * @returns {Promise<{success: boolean, summary: object, errors: Array, modifiedCount: number}>}
4366
- */
4367
- export async function installPack(packIdentifier, user = null, lang = 'en', options = {}) {
4368
- let pack;
4369
- const packsCollection = getCollection('packs');
4370
-
4371
- // Determine if we're working with an ID or direct pack object
4372
- if (typeof packIdentifier === 'string') {
4373
- let p;
4374
- try {
4375
- p = new ObjectId(packIdentifier);
4376
- } catch (e) {
4377
- p = packIdentifier;
4378
- }
4379
- // Existing behavior - fetch from database
4380
- pack = await packsCollection.findOne({ $and: [{ _user: {$exists: false} }, {private: false}, {$or:[{ _id: p}, { name: packIdentifier }]}]} );
4381
- if (!pack) {
4382
- throw new Error(`Pack with ID ${packIdentifier} not found.`);
4383
- }
4384
- } else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
4385
- // New behavior - use provided pack object directly
4386
- pack = packIdentifier;
4387
-
4388
- // Validate basic pack structure
4389
- if (!pack.name || (!pack.models && !pack.data)) {
4390
- throw new Error('Invalid pack structure - must contain at least name and models or data');
4391
- }
4392
- } else {
4393
- throw new Error('Invalid pack identifier - must be either pack ID string or pack object');
4394
- }
4395
-
4396
- const username = user ? user.username : null;
4397
- const logPrefix = username
4398
- ? `Installing pack '${pack.name}' for user '${username}'`
4399
- : `Installing pack '${pack.name}' globally`;
4400
-
4401
- logger.info(`--- ${logPrefix} ---`);
4402
-
4403
- const summary = {
4404
- models: { installed: [], skipped: [], failed: [] },
4405
- datas: { inserted: 0, updated: 0, skipped: 0, failed: 0 }
4406
- };
4407
- const errors = [];
4408
- const collection = user ? await getCollectionForUser(user) : getCollection('data');
4409
- const tempIdToNewIdMap = {};
4410
- const linkCache = new Map();
4411
-
4412
- // --- PHASE 1: MODEL INSTALLATION ---
4413
- if (Array.isArray(pack.models)) {
4414
- // For user installs, check existing models
4415
- const existingModels = user
4416
- ? await modelsCollection.find({ _user: username }).toArray()
4417
- : await modelsCollection.find({ _user: { $exists: false } }).toArray();
4418
-
4419
- console.log("EXISTING", existingModels);
4420
-
4421
- const existingModelNames = existingModels.map(m => m.name);
4422
-
4423
- for (const modelOrName of pack.models) {
4424
- try {
4425
- const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name;
4426
- if (!modelName) throw new Error('Model definition in pack is missing a name.');
4427
-
4428
- if (existingModelNames.includes(modelName)) {
4429
- logger.debug(`[Model Install] Skipping '${modelName}': already exists`);
4430
- summary.models.skipped.push(modelName);
4431
- continue;
4432
- }
4433
-
4434
- const modelToInstall = typeof modelOrName === 'string'
4435
- ? await modelsCollection.findOne({ name: modelName, _user: { $exists: false } })
4436
- : { ...modelOrName };
4437
-
4438
- if (!modelToInstall) {
4439
- throw new Error(`Model '${modelName}' not found in shared models`);
4440
- }
4441
-
4442
- // Prepare model for installation
4443
- const preparedModel = { ...modelToInstall };
4444
- if (user) preparedModel._user = username;
4445
- delete preparedModel._id;
4446
- preparedModel.locked = false;
4447
-
4448
- if (preparedModel.fields) {
4449
- preparedModel.fields.forEach(f => f.locked = false);
4450
- }
4451
-
4452
- await validateModelStructure(preparedModel);
4453
- await modelsCollection.insertOne(preparedModel);
4454
- summary.models.installed.push(modelName);
4455
-
4456
- } catch (e) {
4457
- const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name || 'unknown';
4458
- errors.push(`Failed to install model '${modelName}': ${e.message}`);
4459
- summary.models.failed.push(modelName);
4460
- }
4461
- }
4462
- }
4463
-
4464
- // --- PHASE 2: DATA INSTALLATION ---
4465
- const dataToInstall = { ...pack.data?.all, ...pack.data?.[lang] };
4466
- if (!dataToInstall || Object.keys(dataToInstall).length === 0) {
4467
- logger.warn(`Pack '${pack.name}' has no data to install.`);
4468
- return { success: false, summary, errors, modifiedCount: 0 };
4469
- }
4470
-
4471
- // Process link references (same as original)
4472
- const linkQueue = [];
4473
- for (const modelName in dataToInstall) {
4474
- if (Array.isArray(dataToInstall[modelName])) {
4475
- for (const docSource of dataToInstall[modelName]) {
4476
- const tempId = new ObjectId().toString();
4477
- docSource._temp_pack_id = tempId;
4478
-
4479
- for (const fieldName in docSource) {
4480
- if (isPlainObject(docSource[fieldName]) && docSource[fieldName].$link) {
4481
- linkQueue.push({
4482
- sourceTempId: tempId,
4483
- sourceModelName: modelName,
4484
- fieldName,
4485
- linkSelector: docSource[fieldName].$link
4486
- });
4487
- }
4488
- }
4489
- }
4490
- }
4491
- }
4492
-
4493
- // --- PASS 1: BATCH INSERTION ---
4494
- logger.info("[Pack Install] Starting Pass 1: Batch Insertion & ID Mapping");
4495
- for (const modelName in dataToInstall) {
4496
- if (!Array.isArray(dataToInstall[modelName])) continue;
4497
-
4498
- const documents = dataToInstall[modelName];
4499
- if (documents.length === 0) continue;
4500
-
4501
- const docsToInsert = [];
4502
- const modelDefForHash = await getModel(modelName, user);
4503
-
4504
- for (const docSource of documents) {
4505
- let docForInsert = { ...docSource };
4506
-
4507
- // Clear $link fields for first pass
4508
- for (const key in docForInsert) {
4509
- if (isPlainObject(docForInsert[key]) && docForInsert[key].$link) {
4510
- docForInsert[key] = null;
4511
- }
4512
- }
4513
-
4514
- const tempId = docForInsert._temp_pack_id;
4515
- delete docForInsert._id;
4516
- delete docForInsert._temp_pack_id;
4517
-
4518
- if (user) docForInsert._user = username;
4519
- docForInsert._model = modelName;
4520
- docForInsert._hash = getFieldValueHash(modelDefForHash, docForInsert);
4521
-
4522
- // Check for existing document
4523
- const existingQuery = {
4524
- _hash: docForInsert._hash,
4525
- _model: modelName
4526
- };
4527
- if (user) existingQuery._user = username;
4528
-
4529
- const existingDoc = await collection.findOne(existingQuery, { projection: { _id: 1 } });
4530
- if (existingDoc) {
4531
- tempIdToNewIdMap[tempId] = existingDoc._id;
4532
- summary.datas.skipped++;
4533
- } else {
4534
- docForInsert._temp_pack_id_for_mapping = tempId;
4535
- docsToInsert.push(docForInsert);
4536
- }
4537
- }
4538
-
4539
- if (docsToInsert.length > 0) {
4540
- try {
4541
- const finalDocsToInsert = docsToInsert.map(d => {
4542
- const doc = {...d};
4543
- delete doc._temp_pack_id_for_mapping;
4544
- return doc;
4545
- });
4546
-
4547
- const result = await collection.insertMany(finalDocsToInsert, { ordered: false });
4548
- summary.datas.inserted += result.insertedCount;
4549
-
4550
- docsToInsert.forEach((doc, index) => {
4551
- if (result.insertedIds[index]) {
4552
- tempIdToNewIdMap[doc._temp_pack_id_for_mapping] = result.insertedIds[index];
4553
- }
4554
- });
4555
- } catch (e) {
4556
- summary.datas.failed += docsToInsert.length;
4557
- errors.push(`Error inserting batch for ${modelName}: ${e.message}`);
4558
- logger.error(`[Pack Install] Error on insertMany for model ${modelName}:`, e);
4559
- }
4560
- }
4561
- }
4562
-
4563
- // --- PASS 2: REFERENCE LINKING ---
4564
- logger.info(`[Pack Install] Starting Pass 2: Linking ${linkQueue.length} references`);
4565
- for (const linkOp of linkQueue) {
4566
- const { sourceTempId, sourceModelName, fieldName, linkSelector } = linkOp;
4567
- const sourceId = tempIdToNewIdMap[sourceTempId];
4568
-
4569
- if (!sourceId) {
4570
- logger.warn(`[LINK FAILED] Could not find newly inserted document for temp ID ${sourceTempId}. Skipping link.`);
4571
- continue;
4572
- }
4573
-
4574
- try {
4575
- const targetModelName = linkSelector._model;
4576
- delete linkSelector._model;
4577
-
4578
- const sourceModelDef = await getModel(sourceModelName, user);
4579
- const fieldDef = sourceModelDef.fields.find(f => f.name === fieldName);
4580
-
4581
- if (!fieldDef) {
4582
- logger.warn(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
4583
- errors.push(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
4584
- summary.datas.failed++;
4585
- continue;
4586
- }
4587
-
4588
- // Search for target documents
4589
- const { data: targetDocs } = await searchData(
4590
- { model: targetModelName, filter: linkSelector },
4591
- user
4592
- );
4593
-
4594
- if (!targetDocs || targetDocs.length === 0) {
4595
- const errorMsg = `[LINK FAILED] No target found for ${JSON.stringify(linkSelector)}`;
4596
- logger.warn(errorMsg);
4597
- errors.push(errorMsg);
4598
- summary.datas.failed++;
4599
- continue;
4600
- }
4601
-
4602
- // Update source document with reference
4603
- const valueToSet = fieldDef.multiple
4604
- ? targetDocs.map(d => d._id.toString())
4605
- : targetDocs[0]._id.toString();
4606
-
4607
- await collection.updateOne(
4608
- { _id: sourceId },
4609
- { $set: { [fieldName]: valueToSet } }
4610
- );
4611
- summary.datas.updated++;
4612
-
4613
- } catch (e) {
4614
- const errorMsg = `[LINK CRITICAL] Error linking ${sourceModelName}.${fieldName}: ${e.message}`;
4615
- logger.error(errorMsg, e.stack);
4616
- errors.push(errorMsg);
4617
- summary.datas.failed++;
4618
- }
4619
- }
4620
-
4621
- if( options.installForUser && user?.username ){
4622
- if( pack.name )
4623
- await packsCollection.deleteOne({ name: pack.name, _user: user.username });
4624
- logger.info(`--- Creating pack '${pack.name}' for user... ---`);
4625
- const packToCreate = {...pack, _id: undefined, private: true, _user: user.username };
4626
- await packsCollection.insertOne(packToCreate);
4627
- }
4628
-
4629
- // Trigger event only if pack came from database (original behavior)
4630
- if (typeof packIdentifier === 'string') {
4631
- await Event.Trigger("OnPackInstalled", "event", "system", pack);
4632
- }
4633
-
4634
- const modifiedCount = summary.datas.inserted + summary.datas.updated;
4635
- logger.info(`--- ${logPrefix} completed ---`);
4636
- return {
4637
- success: errors.length === 0,
4638
- summary,
4639
- errors,
4640
- modifiedCount
4641
- };
4642
- }
4643
-
4644
- export const installAllPacks = async () => {
4645
- const packs = await getAllPacks();
4646
- await packsCollection.deleteMany({ _user: { $exists : false }});
4647
- await packsCollection.insertMany(packs.map(p =>({...p, private: false })));
4648
- }
4649
288
 
4650
289
  export async function handleDemoInitialization(req, res) {
4651
290
  const user = req.me;
@@ -4699,7 +338,7 @@ export async function handleDemoInitialization(req, res) {
4699
338
  res.status(200).json({ success: true, message: "Demo environment initialized successfully.", summary: result.summary });
4700
339
  } else {
4701
340
  logger.error(`[Demo Init] Pack installation failed for user '${user.username}'.`);
4702
- res.status(500).json({ success: false, error: 'Demo pack installation failed.', errors: result.errors });
341
+ res.status(200).json({ success: false, error: 'Demo pack installation failed.', errors: result.errors });
4703
342
  }
4704
343
 
4705
344
  } catch (error) {