data-primals-engine 1.4.2 → 1.5.0

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 (59) hide show
  1. package/README.md +878 -856
  2. package/client/package-lock.json +82 -0
  3. package/client/package.json +2 -0
  4. package/client/src/App.jsx +1 -1
  5. package/client/src/App.scss +25 -7
  6. package/client/src/AssistantChat.scss +3 -2
  7. package/client/src/ConditionBuilder.jsx +1 -1
  8. package/client/src/ConditionBuilder2.jsx +2 -1
  9. package/client/src/DashboardView.jsx +569 -569
  10. package/client/src/DataEditor.jsx +376 -368
  11. package/client/src/DataLayout.jsx +4 -10
  12. package/client/src/DataTable.jsx +858 -817
  13. package/client/src/Field.jsx +1825 -1784
  14. package/client/src/FlexDataRenderer.jsx +2 -0
  15. package/client/src/FlexTreeUtils.js +1 -1
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/KPIDialog.jsx +11 -1
  18. package/client/src/ModelCreator.jsx +1 -2
  19. package/client/src/ModelCreatorField.jsx +24 -27
  20. package/client/src/ModelList.jsx +1 -1
  21. package/client/src/RelationField.jsx +2 -2
  22. package/client/src/constants.js +3 -3
  23. package/client/src/filter.js +0 -155
  24. package/client/src/hooks/useTutorials.jsx +62 -65
  25. package/client/src/translations.js +14 -2
  26. package/package.json +2 -1
  27. package/perf/README.md +147 -0
  28. package/perf/artillery-hooks.js +37 -0
  29. package/perf/perf-shot-hardwork.yml +84 -0
  30. package/perf/perf-shot-search.yml +45 -0
  31. package/perf/setup.yml +26 -0
  32. package/server.js +1 -1
  33. package/src/constants.js +264 -31
  34. package/src/core.js +15 -1
  35. package/src/data.js +1 -1
  36. package/src/defaultModels.js +1544 -1540
  37. package/src/email.js +5 -2
  38. package/src/engine.js +10 -3
  39. package/src/filter.js +274 -260
  40. package/src/i18n.js +187 -177
  41. package/src/modules/assistant/assistant.js +3 -1
  42. package/src/modules/bucket.js +12 -15
  43. package/src/modules/data/data.backup.js +11 -8
  44. package/src/modules/data/data.core.js +2 -1
  45. package/src/modules/data/data.js +6 -3
  46. package/src/modules/data/data.operations.js +610 -168
  47. package/src/modules/data/data.routes.js +1821 -1785
  48. package/src/modules/data/data.scheduling.js +2 -1
  49. package/src/modules/data/data.validation.js +7 -1
  50. package/src/modules/file.js +4 -2
  51. package/src/modules/user.js +4 -1
  52. package/src/modules/workflow.js +9 -10
  53. package/src/openai.jobs.js +2 -0
  54. package/src/packs.js +22 -5
  55. package/src/providers.js +22 -7
  56. package/swagger-en.yml +133 -0
  57. package/test/data.integration.test.js +1060 -981
  58. package/test/import_export.integration.test.js +1 -1
  59. package/test/model.integration.test.js +377 -221
@@ -1,9 +1,9 @@
1
- import {getObjectHash, getRandom, isPlainObject, randomDate, safeAssignObject} from "../../core.js";
1
+ import {getObjectHash, getRand, getRandom, isPlainObject, randomDate, safeAssignObject, setSeed} from "../../core.js";
2
2
  import {
3
3
  maxExportCount,
4
4
  maxFileSize,
5
5
  maxFilterDepth,
6
- maxModelNameLength,
6
+ maxModelNameLength, maxPackData,
7
7
  maxPasswordLength,
8
8
  maxPostData,
9
9
  maxRelationsPerData,
@@ -27,7 +27,7 @@ import {
27
27
  packsCollection
28
28
  } from "../mongodb.js";
29
29
  import i18n from "../../i18n.js";
30
- import {randomColor} from "randomcolor";
30
+ import tinycolor from 'tinycolor2';
31
31
  import {Config} from "../../config.js";
32
32
  import {calculateTotalUserStorageUsage, hasPermission} from "../user.js";
33
33
  import {BSON, ObjectId} from "mongodb";
@@ -52,6 +52,7 @@ import {
52
52
  processDocuments,
53
53
  processFileArray
54
54
  } from "./data.relations.js";
55
+ import crypto from 'crypto';
55
56
  import cronstrue from 'cronstrue/i18n.js';
56
57
 
57
58
  const delay = ms => new Promise(res => setTimeout(res, ms));
@@ -91,12 +92,14 @@ export const dataTypes = {
91
92
  },
92
93
  modelField: {
93
94
  validate: (value, field) => {
94
- return value === null || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
95
+ const m = Config.Get('maxStringLength', maxStringLength);
96
+ return value === null || (typeof value === 'string' && value.length < m) || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
95
97
  }
96
98
  },
97
99
  string: {
98
100
  validate: (value, field) => {
99
- const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
101
+ const m = Config.Get('maxStringLength', maxStringLength);
102
+ const ml = Math.min(Math.max(field.maxlength, 0), m);
100
103
  return value === null || typeof value === 'string' && (!ml || value.length <= ml)
101
104
  },
102
105
  anonymize: anonymizeText
@@ -125,7 +128,8 @@ export const dataTypes = {
125
128
  },
126
129
  richtext: {
127
130
  validate: (value, field) => {
128
- const ml = Math.min(Math.max(field.maxlength, 0), maxRichTextLength);
131
+ const m = Config.Get('maxRichTextLength', maxRichTextLength);
132
+ const ml = Math.min(Math.max(field.maxlength, 0), m);
129
133
  return value === null || typeof value === 'string' && (!ml || value.length <= ml)
130
134
  },
131
135
  filter: async (value) => {
@@ -137,6 +141,7 @@ export const dataTypes = {
137
141
  validate: (value, field) => {
138
142
  if (value === null)
139
143
  return true;
144
+ const m = Config.Get('maxStringLength', maxStringLength);
140
145
  const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
141
146
  // La valeur peut être une chaîne de caractères...
142
147
  if (typeof value === 'string') {
@@ -167,7 +172,8 @@ export const dataTypes = {
167
172
  return null;
168
173
  },
169
174
  validate: (value, field) => {
170
- const ml = Math.min(Math.max(field.maxlength, 0), maxPasswordLength);
175
+ const m = Config.Get('maxPasswordLength', maxPasswordLength);
176
+ const ml = Math.min(Math.max(field.maxlength, 0), m);
171
177
  return value === null || typeof value === 'string' && (!ml || value.length <= ml)
172
178
  },
173
179
  anonymize: anonymizeText
@@ -317,7 +323,8 @@ export const dataTypes = {
317
323
  relation: {
318
324
  validate: (value, field) => {
319
325
  if (field.multiple) {
320
- return typeof (value) === 'object' || (Array.isArray(value) && value.length <= maxRelationsPerData && !value.some(v => {
326
+ const m = Config.Get('maxRelationsPerData', maxRelationsPerData);
327
+ return typeof (value) === 'object' || (Array.isArray(value) && value.length <= m && !value.some(v => {
321
328
  return !isObjectId(v);
322
329
  }));
323
330
  }
@@ -346,12 +353,9 @@ export const dataTypes = {
346
353
  }));
347
354
  }
348
355
 
356
+ const m = Config.Get('maxFileSize', maxFileSize);
349
357
  // Check if the file size is within the limit
350
- if (value.size > (field.maxSize || maxFileSize)) {
351
- return false;
352
- }
353
-
354
- return true;
358
+ return value.size <= (field.maxSize || m);
355
359
  }
356
360
 
357
361
  return false; // Invalid type
@@ -367,17 +371,19 @@ export const dataTypes = {
367
371
  },
368
372
  color: {
369
373
  validate: (value) => {
370
- // Vérification si la valeur est une chaîne de caractères et correspond à un format de couleur hexadécimal valide.
371
- return value === null || typeof value === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
374
+ if (value === null) return true;
375
+ if (typeof value !== 'string') return false;
376
+ // Utilise tinycolor pour valider n'importe quel format de couleur supporté (hex, rgb, hsl, etc.)
377
+ return tinycolor(value).isValid();
372
378
  },
373
379
  filter: async (value) => {
374
- // Nettoyage ou transformation de la valeur si nécessaire (par exemple, mise en majuscule des caractères hexadécimaux).
375
- return value ? value.toUpperCase() : null; // Retourne null si la valeur est null ou undefined
380
+ if (!value) return null;
381
+ const color = tinycolor(value);
382
+ // Stocke dans un format canonique : HEX8 (#RRGGBBAA) pour supporter la transparence alpha
383
+ return color.isValid() ? color.toHex8String().toUpperCase() : null;
376
384
  },
377
385
  anonymize: () => {
378
- return randomColor({
379
- format: 'hex'
380
- });
386
+ return '#FFFFFFFF';
381
387
  }
382
388
  },
383
389
  calculated: {
@@ -388,6 +394,37 @@ export const dataTypes = {
388
394
  return value;
389
395
  }
390
396
  },
397
+ geolocation: {
398
+ validate: (value) => {
399
+ if (value === null) return true;
400
+ // Basic GeoJSON structure validation
401
+ if (typeof value !== 'object' || !value.type || !value.coordinates) {
402
+ return false;
403
+ }
404
+ // For now, we only validate 'Point' type, which is the most common.
405
+ if (value.type !== 'Point') {
406
+ // This can be extended to support 'Polygon', 'LineString', etc.
407
+ return false;
408
+ }
409
+ // Validate coordinates for a Point
410
+ return Array.isArray(value.coordinates) &&
411
+ value.coordinates.length === 2 &&
412
+ typeof value.coordinates[0] === 'number' &&
413
+ typeof value.coordinates[1] === 'number';
414
+ },
415
+ anonymize: () => {
416
+ // Generate random coordinates for a GeoJSON Point.
417
+ // Longitude: -180 to 180
418
+ // Latitude: -90 to 90
419
+ setSeed(new Date().getTime()+'');
420
+ const longitude = (getRand() * 360) - 180;
421
+ const latitude = (getRand() * 180) - 90;
422
+ return {
423
+ type: 'Point',
424
+ coordinates: [longitude, latitude]
425
+ };
426
+ }
427
+ },
391
428
  richtext_t: {
392
429
  validate: (value, field) => {
393
430
  // La valeur doit être un objet (ou null/undefined)
@@ -418,6 +455,26 @@ let engine, logger;
418
455
  export function onInit(defaultEngine) {
419
456
  engine = defaultEngine;
420
457
  logger = engine.getComponent(Logger);
458
+
459
+ // Nettoyer périodiquement les relations obsolètes
460
+ setInterval(() => {
461
+ // Nettoyer les relations pour les clés qui n'existent plus
462
+ for (let [key, cacheKeys] of cacheRelations.entries()) {
463
+ const validCacheKeys = new Set();
464
+
465
+ cacheKeys.forEach(cacheKey => {
466
+ if (searchCache.has(cacheKey)) {
467
+ validCacheKeys.add(cacheKey);
468
+ }
469
+ });
470
+
471
+ if (validCacheKeys.size === 0) {
472
+ cacheRelations.delete(key);
473
+ } else {
474
+ cacheRelations.set(key, validCacheKeys);
475
+ }
476
+ }
477
+ }, 300000);
421
478
  }
422
479
 
423
480
  export const editModel = async (user, id, data) => {
@@ -426,6 +483,10 @@ export const editModel = async (user, id, data) => {
426
483
  return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
427
484
  }
428
485
 
486
+ // --- AMÉLIORATION ---
487
+ // Vider complètement le cache de recherche, car une modification de modèle (ex: renommage de champ)
488
+ // peut rendre invalide n'importe quelle recherche mise en cache.
489
+ flushSearchCache();
429
490
  const dataModel = data;
430
491
  try {
431
492
  const collection = await getCollectionForUser(user);
@@ -459,43 +520,82 @@ export const editModel = async (user, id, data) => {
459
520
  })
460
521
  }
461
522
 
462
- const coll = await getCollectionForUser(user);
463
- // Update indexes
464
523
  // Update indexes
465
524
  if (await engine.userProvider.hasFeature(user, 'indexes')) {
525
+ const coll = await getCollectionForUser(user);
466
526
  let indexes = [];
467
527
  try {
468
- // On essaie de récupérer les index existants
469
528
  indexes = await coll.indexes();
470
529
  } catch (e) {
471
- // Si la collection n'existe pas, c'est normal.
472
- // createIndex la créera. Il n'y a juste pas d'index à supprimer.
473
530
  if (e.codeName !== 'NamespaceNotFound') {
474
- throw e; // On relance les autres erreurs
531
+ throw e;
532
+ }
533
+ }
534
+
535
+ const oldFields = el.fields || [];
536
+ const newFields = data.fields || [];
537
+
538
+ // --- Text Index Management (Compound) ---
539
+ const newTextFields = newFields.filter(f => f.index && f.indexType === 'text').map(f => f.name).sort();
540
+ const textIndexName = `_text_search_idx_${data.name}`;
541
+ const existingTextIndex = indexes.find(i => i.name === textIndexName);
542
+ const existingTextFields = existingTextIndex ? Object.keys(existingTextIndex.weights || {}).sort() : [];
543
+
544
+ // Check if the text index definition has changed
545
+ const textIndexChanged = JSON.stringify(existingTextFields) !== JSON.stringify(newTextFields);
546
+
547
+ if (textIndexChanged) {
548
+ if (existingTextIndex) {
549
+ logger.info(`[Index] Dropping existing text index '${textIndexName}' due to changes.`);
550
+ await coll.dropIndex(textIndexName);
551
+ }
552
+ if (newTextFields.length > 0) {
553
+ const textIndexSpec = newTextFields.reduce((acc, fieldName) => {
554
+ acc[fieldName] = 'text';
555
+ return acc;
556
+ }, {});
557
+ const indexOptions = {
558
+ name: textIndexName,
559
+ partialFilterExpression: { _model: data.name, _user: user.username }
560
+ };
561
+ logger.info(`[Index] Creating compound text index on fields: [${newTextFields.join(', ')}].`);
562
+ await coll.createIndex(textIndexSpec, indexOptions);
475
563
  }
476
564
  }
477
565
 
478
- // Le reste de votre logique de gestion d'index peut maintenant s'exécuter en toute sécurité
479
- for (const field of data.fields) {
480
- const elField = el.fields.find(f => f.name === field.name);
481
- if (!elField) continue;
566
+ // --- Regular and 2dsphere Index Management (per field) ---
567
+ const managedFields = newFields.concat(oldFields.filter(oldField => !newFields.some(nf => nf.name === oldField.name)));
568
+
569
+ for (const field of managedFields) {
570
+ const oldField = oldFields.find(f => f.name === field.name);
571
+ const newField = newFields.find(f => f.name === field.name);
572
+ const fieldName = field.name;
573
+
574
+ // Skip text fields, they are handled above
575
+ if ((oldField?.indexType === 'text') || (newField?.indexType === 'text')) continue;
576
+
577
+ const wasIndexed = oldField?.index;
578
+ const isIndexed = newField?.index ?? false;
579
+ const oldIndexType = oldField?.indexType || 'regular';
580
+ const newIndexType = newField?.indexType || 'regular';
581
+ const indexName = `${fieldName}_${newIndexType}_idx`;
582
+ const existingIndex = indexes.find(i => i.key[fieldName] && i.name.startsWith(fieldName));
583
+ const indexExists = !!existingIndex;
584
+ const existingIndexTypeFromName = indexExists ? existingIndex.name.split('_')[1] : null;
482
585
 
483
- const index = indexes.find(i => i.key[field.name] === 1 &&
484
- i.partialFilterExpression?._model === el.name &&
485
- i.partialFilterExpression?._user === user.username);
586
+ const needsUpdate = !newField || (isIndexed !== indexExists) || (isIndexed && indexExists && newIndexType !== existingIndexTypeFromName);
486
587
 
487
- if (elField.index !== field.index && !field.index) {
488
- if (index) {
489
- await coll.dropIndex(index.name);
588
+ if (needsUpdate) {
589
+ if (existingIndex) {
590
+ logger.info(`[Index] Dropping existing index '${existingIndex.name}' for field '${fieldName}' due to changes or deletion.`);
591
+ await coll.dropIndex(existingIndex.name);
490
592
  }
491
- } else if (elField.index !== field.index && field.index) {
492
- if (!index) {
493
- await coll.createIndex({[field.name]: 1}, {
494
- partialFilterExpression: {
495
- _model: data.name,
496
- _user: user.username
497
- }
498
- });
593
+ if (isIndexed && newField) {
594
+ const indexValue = newIndexType === '2dsphere' ? '2dsphere' : 1;
595
+ const indexSpec = { [fieldName]: indexValue };
596
+ const indexOptions = { name: indexName, partialFilterExpression: { _model: data.name, _user: user.username } };
597
+ logger.info(`[Index] Creating '${newIndexType}' index on field '${fieldName}'.`);
598
+ await coll.createIndex(indexSpec, indexOptions);
499
599
  }
500
600
  }
501
601
  }
@@ -583,6 +683,10 @@ export const insertData = async (modelName, data, files, user, triggerWorkflow =
583
683
  };
584
684
  }
585
685
 
686
+ // Invalider le cache pour ce modèle puisque de nouvelles données ont été ajoutées.
687
+ invalidateModelCache(modelName);
688
+
689
+
586
690
  // Convertir les IDs en ObjectId pour la recherche
587
691
  const objectIds = insertedIds.map(id => new ObjectId(id));
588
692
  const insertedDocs = await collection.find({_id: {$in: objectIds}}).toArray();
@@ -864,11 +968,13 @@ async function checkLimits(datas, model, collection, me) {
864
968
 
865
969
  // Vérification nombre max de documents
866
970
  const count = await collection.countDocuments({_user: me._user || me.username});
867
- if (count + datas.length > maxTotalDataPerUser) {
971
+ const m = Config.Get('maxTotalDataPerUser', maxTotalDataPerUser);
972
+ if (count + datas.length > m) {
868
973
  throw new Error(i18n.t("api.data.tooManyData"));
869
974
  }
870
975
 
871
- if (datas.length > maxPostData) {
976
+ const mp = Config.Get('maxPostData', maxPostData);
977
+ if (datas.length > mp) {
872
978
  throw new Error(i18n.t('api.data.tooManyData'));
873
979
  }
874
980
  }
@@ -885,6 +991,18 @@ function calculateDataSize(datas) {
885
991
  }
886
992
  }
887
993
 
994
+ // Fonction utilitaire pour trouver les modèles liés
995
+ async function findRelatedModels(modelName, user) {
996
+ try {
997
+ return await modelsCollection.find({
998
+ "fields.relation": modelName,
999
+ _user: user.username
1000
+ }).toArray();
1001
+ } catch (error) {
1002
+ console.error("Error finding related models:", error);
1003
+ return [];
1004
+ }
1005
+ }
888
1006
  export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
889
1007
  return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
890
1008
  };
@@ -1054,6 +1172,18 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1054
1172
  const bulkResult = await collection.bulkWrite(bulkOps);
1055
1173
  const modifiedCount = bulkResult.modifiedCount || 0;
1056
1174
 
1175
+ if (modifiedCount > 0) {
1176
+ // Invalider le cache pour les IDs modifiés
1177
+ const idsToInvalidate = ids.map(id => id.toString());
1178
+ invalidateIdsCache(modelName, idsToInvalidate);
1179
+ // Invalider aussi les modèles qui pourraient référencer ces documents
1180
+ const relatedModels = await findRelatedModels(modelName, user);
1181
+ for (const relatedModel of relatedModels) {
1182
+ invalidateModelCache(relatedModel.name);
1183
+ console.log(`Also invalidated cache for related model: ${relatedModel.name}`);
1184
+ }
1185
+ console.log(`Invalidated cache for ${idsToInvalidate.length} documents in model: ${modelName}`);
1186
+ }
1057
1187
  // Déclencher l'événement OnDataEdited avec les états avant/après
1058
1188
  if (modifiedCount > 0) {
1059
1189
  const updatedDocs = await collection.find({_id: {$in: ids}}).toArray();
@@ -1135,6 +1265,7 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1135
1265
  }
1136
1266
 
1137
1267
  const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
1268
+ const idsToInvalidate = []; // *** AJOUT: IDs à invalider dans le cache ***
1138
1269
 
1139
1270
  for (const docToDelete of documentsToDelete) {
1140
1271
  const deletePromises = [];
@@ -1278,7 +1409,7 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1278
1409
 
1279
1410
  // Ajouter l'ID à la liste finale si la permission est accordée
1280
1411
  finalIdsToDelete.push(docToDelete._id);
1281
-
1412
+ idsToInvalidate.push(docToDelete._id.toString());
1282
1413
  } // Fin de la boucle sur documentsToDelete
1283
1414
 
1284
1415
  // 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
@@ -1288,6 +1419,9 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1288
1419
  _id: {$in: finalIdsToDelete}
1289
1420
  // Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
1290
1421
  });
1422
+ invalidateIdsCache(modelName, idsToInvalidate);
1423
+ console.log(`Invalidated cache for ${idsToInvalidate.length} deleted documents in model: ${modelName}`);
1424
+
1291
1425
  deletedCount = result.deletedCount;
1292
1426
  logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
1293
1427
  } else {
@@ -1309,8 +1443,101 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1309
1443
  });
1310
1444
  }
1311
1445
  }
1446
+
1447
+
1448
+ // List of operators that cannot be used inside $expr. $geoNear is handled separately
1449
+ // as it's a full stage, not just an operator.
1450
+ const specialOpKeys = ['$text', '$near', '$nearSphere', '$geoWithin', '$geoIntersects', '$regex'];
1451
+
1452
+ /**
1453
+ * Recursively checks if any part of a filter expression contains a special operator.
1454
+ * @param {*} expression - The filter expression or a part of it.
1455
+ * @returns {boolean}
1456
+ */
1457
+ const containsSpecialOp = (expression) => {
1458
+ if (Array.isArray(expression)) {
1459
+ return expression.some(item => containsSpecialOp(item));
1460
+ }
1461
+ if (!isPlainObject(expression)) {
1462
+ return false;
1463
+ }
1464
+
1465
+ for (const key in expression) {
1466
+ if (specialOpKeys.includes(key)) {
1467
+ return true;
1468
+ }
1469
+ if (containsSpecialOp(expression[key])) {
1470
+ return true;
1471
+ }
1472
+ }
1473
+ return false;
1474
+ };
1475
+
1476
+ /**
1477
+ * Transforms a simple $find condition object into a full MongoDB aggregation expression.
1478
+ * e.g., { relatedValue: 101 } becomes { $eq: ['$$this.relatedValue', 101] }
1479
+ * e.g., { name: 'A', value: 1 } becomes { $and: [{ $eq: ['$$this.name', 'A'] }, { $eq: ['$$this.value', 1] }] }
1480
+ * @param {object} findCondition - The condition object from the $find operator.
1481
+ * @returns {object} The complete MongoDB aggregation expression.
1482
+ */
1483
+ function transformFindShorthand(findCondition) {
1484
+ // If it's not a plain object, do nothing.
1485
+ if (typeof findCondition !== 'object' || findCondition === null || Array.isArray(findCondition)) {
1486
+ return findCondition;
1487
+ }
1488
+
1489
+ const keys = Object.keys(findCondition);
1490
+
1491
+ // If the object is empty or already contains MongoDB operators (keys starting with '$'),
1492
+ // assume it's already in the full format and don't modify it.
1493
+ if (keys.length === 0 || keys.some(key => key.startsWith('$'))) {
1494
+ return findCondition;
1495
+ }
1496
+
1497
+ // It's the shorthand format. Transform it.
1498
+ // Create an $eq condition for each key/value pair.
1499
+ const conditions = keys.map(key => ({
1500
+ $eq: [`$$this.${key}`, findCondition[key]]
1501
+ }));
1502
+
1503
+ // If there's only one condition, return the $eq object directly.
1504
+ if (conditions.length === 1) return conditions[0];
1505
+
1506
+ // If there are multiple conditions, combine them with an $and.
1507
+ return { $and: conditions };
1508
+ }
1509
+
1510
+ // Fonction pour générer une clé de cache unique
1511
+ const generateCacheKey = (query, user) => {
1512
+ const keyData = {
1513
+ query: {
1514
+ page: query.page,
1515
+ limit: query.limit,
1516
+ sort: query.sort,
1517
+ model: query.model,
1518
+ pipelinesPosition: query.pipelinesPosition,
1519
+ customPipelines: query.pipelines || [],
1520
+ ids: query.ids,
1521
+ filter: query.filter,
1522
+ depth: query.depth,
1523
+ autoExpand: query.autoExpand,
1524
+ pack: query.pack
1525
+ },
1526
+ user: user.username
1527
+ };
1528
+
1529
+ return crypto.createHash('sha256').update(JSON.stringify(keyData)).digest('hex');
1530
+ };
1531
+
1532
+ // Configuration du cache (TTL de 5 minutes par défaut)
1533
+ const searchCache = new NodeCache({
1534
+ stdTTL: 300, // 5 minutes
1535
+ checkperiod: 60, // Vérification des expirations toutes les 60 secondes
1536
+ useClones: false // Meilleures performances
1537
+ });
1538
+
1312
1539
  export const searchData = async (query, user) => {
1313
- const {page, limit, sort, model, ids, timeout, pack} = query; // Les filtres de la requête (attention aux injections MongoDB !)
1540
+ const {page, limit, sort, model, pipelinesPosition, pipelines: customPipelines = [], ids, timeout, pack} = query;
1314
1541
 
1315
1542
  if (user && user.username !== 'demo' && isLocalUser(user) && (
1316
1543
  !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_" + model], user) ||
@@ -1318,35 +1545,88 @@ export const searchData = async (query, user) => {
1318
1545
  throw new Error(i18n.t('api.permission.searchData'));
1319
1546
  }
1320
1547
 
1548
+ const cacheKey = generateCacheKey(query, user);
1549
+ // Vérifier si les données sont en cache
1550
+ const cachedData = searchCache.get(cacheKey);
1551
+ if (cachedData) {
1552
+ console.log('Cache hit for key:', cacheKey);
1553
+ return cachedData;
1554
+ }
1555
+
1321
1556
  const collection = await getCollectionForUser(user);
1322
1557
  const modelElement = await getModel(model, user);
1323
1558
 
1324
1559
  const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
1325
1560
  return new ObjectId(m);
1326
1561
  });
1327
- let l = Math.min(modelElement.maxRequestData || maxRequestData, limit ? parseInt(limit, 10) : maxRequestData);
1562
+ let m = Config.Get('maxRequestData', maxRequestData);
1563
+ let l = Math.min(modelElement.maxRequestData || m, limit ? parseInt(limit, 10) : m)
1328
1564
  let p = parseInt(page, 10);
1329
1565
  let filter = query.filter || {};
1330
1566
 
1331
- let sortObj = {};
1332
- sort?.split(',').forEach(s => {
1333
- const v = s.split(':');
1334
- sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
1335
- })
1336
- if (!sort) {
1337
- sortObj = {[modelElement.fields[0]?.name || '_id']: ['datetime', 'date'].includes(modelElement.fields[0].type) ? -1 : 1};
1567
+ // --- START: Added logic for special query operators ---
1568
+ const specialFilterOps = {};
1569
+ let standardFilter = {};
1570
+
1571
+ // Recursively separate special operators ($text, $nearSphere, etc.) that cannot be used within $expr
1572
+ for (const key in filter) {
1573
+ const value = filter[key];
1574
+
1575
+ // $geoNear is a special case; it's a stage and must be at the top level of the filter.
1576
+ if (key === '$geoNear') {
1577
+ specialFilterOps[key] = value;
1578
+ }
1579
+ // For logical operators, we split their child arrays based on whether they contain special ops.
1580
+ else if ((key === '$and' || key === '$or' || key === '$nor') && Array.isArray(value)) {
1581
+ const hasSpecial = value.some(child => containsSpecialOp(child));
1582
+
1583
+ // If a logical operator contains any condition with a special operator (like $regex or a geo-op),
1584
+ // the entire logical block must be processed in a standard `$match` stage.
1585
+ // This is because splitting the block would break the original logic (e.g., an `$or` would become an `$and`).
1586
+ if (hasSpecial) {
1587
+ specialFilterOps[key] = value;
1588
+ } else {
1589
+ // Otherwise, the entire block is "standard" and can be processed by the $expr-based logic.
1590
+ standardFilter[key] = value;
1591
+ }
1592
+ }
1593
+ // For other keys, check if the expression {key: value} contains a special op.
1594
+ else if (containsSpecialOp({ [key]: value })) {
1595
+ specialFilterOps[key] = value;
1596
+ } else {
1597
+ standardFilter[key] = value;
1598
+ }
1599
+ }
1600
+
1601
+ // The rest of the function will use `standardFilter` for the recursive lookup.
1602
+ filter = standardFilter;
1603
+ // --- END: Added logic for special query operators ---
1604
+
1605
+ let sortObj = null; // Initialize to null
1606
+ if (sort) {
1607
+ sortObj = {};
1608
+ sort.split(',').forEach(s => {
1609
+ const v = s.split(':');
1610
+ sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
1611
+ });
1338
1612
  }
1339
1613
 
1340
1614
  let i = 0;
1341
1615
  const f = {...filter};
1342
1616
 
1343
- let depthParam = Math.max(1, Math.min(maxFilterDepth, typeof (query.depth) === 'string' ? parseInt(query.depth) : (typeof (query.depth) === 'number' ? query.depth : 1)));
1617
+ let mf = Config.Get('maxFilterDepth', maxFilterDepth);
1618
+ let depthParam = Math.max(1, Math.min(mf, typeof (query.depth) === 'string' ? parseInt(query.depth) : (typeof (query.depth) === 'number' ? query.depth : 1)));
1344
1619
  let autoExpand = typeof (query.autoExpand) === 'undefined' || (typeof (query.autoExpand) === 'string' && ['1', 'true'].includes(query.autoExpand.toLowerCase()));
1345
1620
 
1346
1621
  const recursiveLookup = async (model, data, depth = 1, already = [], parentPath = '') => {
1347
1622
 
1348
- if (depth > depthParam)
1623
+ if (depth > depthParam) {
1349
1624
  return [];
1625
+ }
1626
+ // Handle null, array, or other non-object data gracefully to prevent crashes.
1627
+ if (!isPlainObject(data)) {
1628
+ return [];
1629
+ }
1350
1630
 
1351
1631
  let pipelines = [], pipelinesLookups = [];
1352
1632
  let modelElement;
@@ -1364,13 +1644,14 @@ export const searchData = async (query, user) => {
1364
1644
  if (!field || !name)
1365
1645
  return {};
1366
1646
  if (field.type === "relation") {
1647
+ const findCondition = transformFindShorthand(d);
1367
1648
  const dt = {
1368
1649
  '$ne': [
1369
1650
  {
1370
1651
  '$filter': {
1371
1652
  'input': (depth === 1 ? "$" + name : "$this." + name),
1372
1653
  'as': 'this',
1373
- 'cond': d
1654
+ 'cond': findCondition
1374
1655
  }
1375
1656
  }
1376
1657
  , []]
@@ -1396,16 +1677,13 @@ export const searchData = async (query, user) => {
1396
1677
  return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
1397
1678
  })) {
1398
1679
 
1399
- // **Circular Reference Check:**
1400
1680
  if (already.includes(fi.relation)) {
1401
- // Skip the lookup if we've already processed this relation in the current chain.
1402
1681
  console.warn(`Skipping circular reference to model: ${fi.relation}`);
1403
1682
  continue;
1404
1683
  }
1405
1684
  const relSort = {};
1406
1685
  if (fi.type === 'relation' && depthParam !== 1) {
1407
- delete f[fi.name];
1408
- if (sortObj[fi.name]) {
1686
+ if (sortObj?.[fi.name]) {
1409
1687
 
1410
1688
  const sortColumn = await getModel(fi.relation, user);
1411
1689
  let t = sortColumn.fields.find(f => f.asMain)?.name;
@@ -1428,7 +1706,6 @@ export const searchData = async (query, user) => {
1428
1706
  }
1429
1707
  }
1430
1708
 
1431
- // Création du lookup si l'expand est activé
1432
1709
  ++i;
1433
1710
  const lookup = {
1434
1711
  $lookup: {
@@ -1448,7 +1725,7 @@ export const searchData = async (query, user) => {
1448
1725
  fi.multiple ? {
1449
1726
  $in: [{$toString: "$_id"}, {
1450
1727
  $map: {
1451
- input: {$ifNull: ["$$convertedId", []]}, // On utilise le tableau d'IDs, ou un tableau vide s'il est null
1728
+ input: {$ifNull: ["$$convertedId", []]},
1452
1729
  as: "relationId",
1453
1730
  in: {$toString: "$$relationId"}
1454
1731
  }
@@ -1464,15 +1741,16 @@ export const searchData = async (query, user) => {
1464
1741
  }
1465
1742
  }
1466
1743
  },
1467
- {$limit: maxRelationsPerData}
1744
+ {$limit: Config.Get('maxRelationsPerData', maxRelationsPerData)}
1468
1745
  ]
1469
1746
  }
1470
1747
  };
1471
1748
 
1472
1749
  pipelinesLookups.push(lookup);
1473
- pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
1474
1750
 
1475
- // Construct the path for the current field
1751
+ const m = Config.Get('maxTotalDataPerUser', maxTotalDataPerUser);
1752
+ pipelinesLookups.push({$limit: Math.floor(m)});
1753
+
1476
1754
  const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
1477
1755
  fi.path = currentPath;
1478
1756
  pipelinesLookups.push(
@@ -1489,7 +1767,6 @@ export const searchData = async (query, user) => {
1489
1767
  }
1490
1768
  );
1491
1769
 
1492
- //found = true;
1493
1770
  pipelinesLookups.push(
1494
1771
  {$project: {['items' + i]: 0}}
1495
1772
  );
@@ -1517,13 +1794,10 @@ export const searchData = async (query, user) => {
1517
1794
  );
1518
1795
 
1519
1796
  } else if (fi.type === 'file') {
1520
- // Logique pour enrichir un champ fichier unique
1521
-
1522
- // Stage 1: Lookup file details from the 'files' collection
1523
1797
  pipelinesLookups.push({
1524
1798
  $lookup: {
1525
- from: "files", // The global collection where file metadata is stored
1526
- let: {fileGuid: '$' + fi.name}, // The GUID string from the current document's field
1799
+ from: "files",
1800
+ let: {fileGuid: '$' + fi.name},
1527
1801
  pipeline: [
1528
1802
  {
1529
1803
  $match: {
@@ -1535,95 +1809,75 @@ export const searchData = async (query, user) => {
1535
1809
  }
1536
1810
  }
1537
1811
  },
1538
- {$limit: 1} // GUIDs should be unique, so limit to 1
1812
+ {$limit: 1}
1539
1813
  ],
1540
- as: fi.name + "_details_temp" // Temporary field to store the lookup result (an array)
1814
+ as: fi.name + "_details_temp"
1541
1815
  }
1542
1816
  });
1543
1817
 
1544
- // Stage 2: Replace the original GUID string with the fetched file object (or null if not found)
1545
1818
  pipelinesLookups.push({
1546
1819
  $addFields: {
1547
1820
  [fi.name]: {
1548
- // $lookup returns an array, take the first element.
1549
- // If lookup result is empty or null, set the field to null.
1550
1821
  $ifNull: [{$first: '$' + fi.name + "_details_temp"}, null]
1551
1822
  }
1552
1823
  }
1553
1824
  });
1554
1825
 
1555
- // Stage 3: Clean up the temporary lookup field
1556
1826
  pipelinesLookups.push({
1557
1827
  $project: {
1558
1828
  [fi.name + "_details_temp"]: 0
1559
1829
  }
1560
1830
  });
1561
1831
  } else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
1562
- // This field (e.g., 'myImageGallery') stores an array of GUID strings: ["guid1", "guid2"]
1563
1832
  pipelinesLookups.push(
1564
1833
  {
1565
1834
  $lookup: {
1566
- from: "files", // The global collection where file metadata is stored
1567
- let: {localGuidsArray: '$' + fi.name}, // The array of GUID strings from the current document
1835
+ from: "files",
1836
+ let: {localGuidsArray: '$' + fi.name},
1568
1837
  pipeline: [
1569
1838
  {
1570
1839
  $match: {
1571
1840
  $expr: {
1572
- // Match documents in "files" collection where 'guid' is in the '$$localGuidsArray'
1573
- $in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}] // Handle null or missing array
1841
+ $in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}]
1574
1842
  }
1575
1843
  }
1576
1844
  }
1577
- // Optional: Project only necessary fields from the "files" collection if needed
1578
- // {
1579
- // $project: {
1580
- // _id: 0, // Exclude MongoDB's _id from the 'files' collection documents
1581
- // // mainUser: 0, // Example: if you don't need these in the result
1582
- // // user: 0,
1583
- // // _model:0, // The _model "privateFile" might not be useful here
1584
- // // Keep: guid, filename (as name), mimetype, size, timestamp etc.
1585
- // }
1586
- // }
1587
1845
  ],
1588
- as: fi.name + "_details_temp" // Temporary field to store the array of matched file detail objects
1846
+ as: fi.name + "_details_temp"
1589
1847
  }
1590
1848
  },
1591
- // The following $addFields and $project stages are what you had
1592
- // in your fi.type === 'file' block, and they are correct for this array scenario.
1593
1849
  {
1594
1850
  $addFields: {
1595
- [fi.name]: { // Remplacer le tableau de chaînes GUID par un tableau d'objets fichiers détaillés
1596
- $ifNull: [ // Gérer le cas où le champ fi.name est null (original array of GUIDs)
1851
+ [fi.name]: {
1852
+ $ifNull: [
1597
1853
  {
1598
1854
  $map: {
1599
- input: '$' + fi.name, // Itérer sur le tableau original de chaînes GUID
1600
- as: "originalGuidString", // Each element from the input array (a GUID string)
1855
+ input: '$' + fi.name,
1856
+ as: "originalGuidString",
1601
1857
  in: {
1602
1858
  $let: {
1603
1859
  vars: {
1604
- // Trouver le détail correspondant dans _details_temp par GUID
1605
1860
  matchedDetail: {
1606
1861
  $arrayElemAt: [
1607
1862
  {
1608
1863
  $filter: {
1609
- input: '$' + fi.name + "_details_temp", // Use the result from the $lookup above
1610
- as: "detailFile", // Each document from _details_temp
1864
+ input: '$' + fi.name + "_details_temp",
1865
+ as: "detailFile",
1611
1866
  cond: {$eq: ["$$detailFile.guid", "$$originalGuidString"]}
1612
1867
  }
1613
1868
  },
1614
- 0 // Take the first match (GUIDs should be unique in "files")
1869
+ 0
1615
1870
  ]
1616
1871
  }
1617
1872
  },
1618
1873
  in: {
1619
1874
  $cond: {
1620
- if: '$$matchedDetail', // Si des détails ont été trouvés
1621
- then: '$$matchedDetail', // Utiliser l'objet détaillé complet
1622
- else: { // Si aucun détail trouvé pour ce GUID (e.g., broken reference)
1623
- guid: '$$originalGuidString', // Conserver le GUID original
1624
- name: null, // Ou une valeur par défaut comme "Fichier inconnu"
1875
+ if: '$$matchedDetail',
1876
+ then: '$$matchedDetail',
1877
+ else: {
1878
+ guid: '$$originalGuidString',
1879
+ name: null,
1625
1880
  _error: "File details not found"
1626
- // Ou simplement: '$$originalGuidString' si vous voulez juste garder la chaîne
1627
1881
  }
1628
1882
  }
1629
1883
  }
@@ -1631,13 +1885,13 @@ export const searchData = async (query, user) => {
1631
1885
  }
1632
1886
  }
1633
1887
  },
1634
- [] // Si le champ original fi.name était null, le résultat est un tableau vide
1888
+ []
1635
1889
  ]
1636
1890
  }
1637
1891
  }
1638
1892
  },
1639
1893
  {
1640
- $project: { // Nettoyer le champ temporaire
1894
+ $project: {
1641
1895
  [fi.name + "_details_temp"]: 0
1642
1896
  }
1643
1897
  }
@@ -1645,28 +1899,19 @@ export const searchData = async (query, user) => {
1645
1899
  } else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
1646
1900
  const calcPipelineAbstract = fi.calculation.pipeline;
1647
1901
  const calcFinalFieldName = fi.calculation.final;
1648
- const tempLookupsForThisCalcField = []; // Pour stocker les noms 'as' des lookups de CE champ calculé
1902
+ const tempLookupsForThisCalcField = [];
1649
1903
 
1650
- // Ajouter les étapes $lookup définies par le calcul
1651
1904
  if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
1652
1905
  for (const lookupDef of calcPipelineAbstract.lookups) {
1653
- // ... (votre logique existante de vérification de foreignModel et localField) ...
1654
- // Assurez-vous que cette logique est robuste comme discuté précédemment.
1655
- // Si une erreur se produit ici (foreignModel non trouvé, etc.),
1656
- // vous ajoutez déjà un $addFields pour initialiser lookupDef.as à null/[]
1657
- // et vous faites 'continue'. C'est bien.
1658
-
1659
- // Si tout va bien, on construit le lookup :
1660
- const targetCollectionName = await getUserCollectionName(user);
1661
- const localFieldValueInPipeline = `$${lookupDef.localField}`;
1662
-
1663
- // Vérification basique du localField (déjà présente dans votre code précédent)
1664
1906
  if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
1665
1907
  logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
1666
1908
  pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
1667
1909
  continue;
1668
1910
  }
1669
1911
 
1912
+ const targetCollectionName = await getUserCollectionName(user);
1913
+ const localFieldValueInPipeline = `$${lookupDef.localField}`;
1914
+
1670
1915
  const mongoLookupStage = {
1671
1916
  $lookup: {
1672
1917
  from: targetCollectionName,
@@ -1685,13 +1930,12 @@ export const searchData = async (query, user) => {
1685
1930
  }
1686
1931
  }
1687
1932
  }
1688
- // Optionnel: Projeter uniquement les champs nécessaires
1689
1933
  ],
1690
1934
  as: lookupDef.as
1691
1935
  }
1692
1936
  };
1693
1937
  pipelinesLookups.push(mongoLookupStage);
1694
- tempLookupsForThisCalcField.push(lookupDef.as); // Suivre ce champ temporaire
1938
+ tempLookupsForThisCalcField.push(lookupDef.as);
1695
1939
 
1696
1940
  if (!lookupDef.isMultiple) {
1697
1941
  pipelinesLookups.push({
@@ -1704,38 +1948,30 @@ export const searchData = async (query, user) => {
1704
1948
  }
1705
1949
  }
1706
1950
 
1707
- // Ajouter l'étape $addFields pour les calculs eux-mêmes
1708
1951
  if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
1709
1952
  const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({$addFields: {[m]: calcPipelineAbstract.addFields[m]}}));
1710
1953
  pipelinesLookups = pipelinesLookups.concat(addFields);
1711
1954
  }
1712
1955
 
1713
- // S'assurer que le champ final du calcul (calcFinalFieldName) est bien accessible
1714
- // sous le nom du champ du modèle (fi.name).
1715
1956
  if (calcFinalFieldName !== fi.name) {
1716
1957
  pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
1717
1958
  }
1718
1959
 
1719
- // --- NOUVEAU : Supprimer les champs de lookup temporaires pour CE champ calculé ---
1720
1960
  if (tempLookupsForThisCalcField.length > 0) {
1721
1961
  const unsetProjection = {};
1722
1962
  for (const tempField of tempLookupsForThisCalcField) {
1723
- // On peut supprimer tous les champs __calc_lookup_... car le CalculationBuilder
1724
- // empêche que outputAlias (et donc calcFinalFieldName, et donc fi.name)
1725
- // soit un de ces champs temporaires.
1726
- unsetProjection[tempField] = 0; // 0 signifie supprimer/exclure le champ
1963
+ unsetProjection[tempField] = 0;
1727
1964
  }
1728
1965
  if (Object.keys(unsetProjection).length > 0) {
1729
1966
  pipelinesLookups.push({$project: unsetProjection});
1730
1967
  }
1731
1968
  }
1732
1969
  } else if (fi.type === 'array') {
1733
- // Handle array filtering here
1734
1970
  if (data[fi.name]) {
1735
1971
  pipelines.push({
1736
1972
  $match: {
1737
1973
  $expr: {
1738
- $in: [data[fi.name], '$' + fi.name] // Check if the array contains the value
1974
+ $in: [data[fi.name], '$' + fi.name]
1739
1975
  }
1740
1976
  }
1741
1977
  });
@@ -1753,64 +1989,256 @@ export const searchData = async (query, user) => {
1753
1989
  }
1754
1990
  }
1755
1991
 
1756
-
1757
- let addFields = [];
1758
- /*modelElement.fields.forEach(field => {
1759
- if( field.type==='relation' && !field.multiple && depthParam !== 1 && (dataRelationF.length)){
1760
- addFields.push(
1761
- {$addFields: {[`${field.name}`]: {$first: '$'+field.name }}}
1762
- )
1763
- }
1764
- })*/
1765
- return pipelines.concat([{$match: {'_pack': pack ? pack : {$exists: false}}}, {$match: {$expr: dataNoRelation}}], pipelinesLookups, addFields, [{$match: {$expr: {$and: dataRelationF}}}]);
1992
+ return pipelines.concat(
1993
+ [
1994
+ {$match: {'_pack': pack ? pack : {$exists: false}}},
1995
+ {$match: {$expr: dataNoRelation}}
1996
+ ],
1997
+ customPipelines, // INTÉGRATION DES PIPELINES PERSONNALISÉES
1998
+ pipelinesLookups,
1999
+ [{$match: {$expr: {$and: dataRelationF}}}]
2000
+ );
1766
2001
  };
1767
2002
 
1768
2003
  let pipelines = [];
1769
- if (allIds.length) {
1770
- const id = {$in: ["$_id", allIds.map(m => new ObjectId(m))]};
1771
- pipelines.push({
1772
- $match: {$expr: id}
1773
- });
1774
2004
 
1775
- } else {
2005
+ // --- START: Modified pipeline construction for special operators ---
2006
+ if (specialFilterOps.$geoNear && (Object.values(specialFilterOps).some(v => v?.$nearSphere) || specialFilterOps.$text)) {
2007
+ throw new Error("A $geoNear stage cannot be combined with $nearSphere or $text operators in the same query.");
2008
+ }
2009
+
2010
+ // --- Strategy ---
2011
+ // 1. If a $nearSphere operator is found, convert it to a $geoNear stage. This must be the first stage.
2012
+ // Other special filters (like $regex) will be moved into the `query` part of the $geoNear stage.
2013
+ // 2. If a user-provided $geoNear stage exists, use it. It must be the first stage.
2014
+ // 3. If a $text operator is found, it must be in the first $match stage. It is mutually exclusive with geo-queries.
2015
+
2016
+ let nearSphereField = null;
2017
+ let nearSphereKey = null;
2018
+ for (const key in specialFilterOps) {
2019
+ if (isPlainObject(specialFilterOps[key]) && specialFilterOps[key].$nearSphere) {
2020
+ if (nearSphereField) {
2021
+ throw new Error("Query cannot contain multiple $nearSphere operators. Use a single $geoNear stage for complex geo-queries.");
2022
+ }
2023
+ nearSphereField = specialFilterOps[key].$nearSphere;
2024
+ nearSphereKey = key;
2025
+ }
2026
+ }
1776
2027
 
1777
- pipelines.push(
1778
- {
1779
- $match: {
1780
- $expr: {
1781
- $and: [{$eq: ["$_model", modelElement.name]},
1782
- {$eq: ["$_user", user.username]}]
2028
+ if (nearSphereField) {
2029
+ // A $geoNear stage must be the first stage.
2030
+ if (specialFilterOps.$geoNear || specialFilterOps.$text) {
2031
+ throw new Error("Cannot use $nearSphere with a $geoNear stage or a $text operator.");
2032
+ }
2033
+
2034
+ const geoNearStage = {
2035
+ near: nearSphereField.$geometry,
2036
+ distanceField: "distance", // Default distance field name
2037
+ key: nearSphereKey, // The field to perform the search on
2038
+ spherical: true,
2039
+ query: { // Base query for model and user
2040
+ _model: modelElement.name,
2041
+ _user: user.username
2042
+ }
2043
+ };
2044
+
2045
+ // Conditionally add distance fields to avoid passing 'undefined' to MongoDB
2046
+ if (nearSphereField.$maxDistance !== undefined) {
2047
+ geoNearStage.maxDistance = nearSphereField.$maxDistance;
2048
+ }
2049
+ if (nearSphereField.$minDistance !== undefined) {
2050
+ geoNearStage.minDistance = nearSphereField.$minDistance;
2051
+ }
2052
+
2053
+ // Remove the processed nearSphere operator from specialFilterOps
2054
+ delete specialFilterOps[nearSphereKey];
2055
+
2056
+ // Add any other special filters (like $regex) to the $geoNear query
2057
+ Object.assign(geoNearStage.query, specialFilterOps);
2058
+
2059
+ if (allIds.length > 0) {
2060
+ geoNearStage.query._id = { $in: allIds };
2061
+ }
2062
+
2063
+ pipelines.push({ $geoNear: geoNearStage });
2064
+
2065
+ // Clear specialFilterOps as they've all been moved into the $geoNear query
2066
+ Object.keys(specialFilterOps).forEach(key => delete specialFilterOps[key]);
2067
+
2068
+ } else if (specialFilterOps.$geoNear) {
2069
+ // Handle a user-provided $geoNear stage
2070
+ const geoNearStage = { ...specialFilterOps.$geoNear };
2071
+ geoNearStage.query = {
2072
+ ...(geoNearStage.query || {}),
2073
+ _model: modelElement.name,
2074
+ _user: user.username
2075
+ };
2076
+ if (allIds.length > 0) {
2077
+ geoNearStage.query._id = { $in: allIds };
2078
+ }
2079
+ pipelines.push({ $geoNear: geoNearStage });
2080
+ delete specialFilterOps.$geoNear;
2081
+
2082
+ } else if (Object.keys(specialFilterOps).length > 0) {
2083
+ // Handle other special operators like $text and $regex if no geo-query was present.
2084
+ const standardMatchQueries = {};
2085
+ if (specialFilterOps.$text) {
2086
+ standardMatchQueries.$text = specialFilterOps.$text;
2087
+ delete specialFilterOps.$text;
2088
+ }
2089
+ Object.assign(standardMatchQueries, specialFilterOps);
2090
+
2091
+ standardMatchQueries._model = modelElement.name;
2092
+ standardMatchQueries._user = user.username;
2093
+ if (allIds.length > 0) {
2094
+ standardMatchQueries._id = { $in: allIds };
2095
+ }
2096
+ pipelines.push({ $match: standardMatchQueries });
2097
+ }
2098
+
2099
+ // Add the original initial match logic, but only if no pipeline stages have been created yet.
2100
+ if (pipelines.length === 0) {
2101
+ if (allIds.length) {
2102
+ // Note: allIds are already ObjectIds from the start of the function.
2103
+ const id = {$in: ["$_id", allIds]};
2104
+ pipelines.push({
2105
+ $match: {$expr: id}
2106
+ });
2107
+ } else {
2108
+ pipelines.push(
2109
+ {
2110
+ $match: {
2111
+ $expr: {
2112
+ $and: [
2113
+ {$eq: ["$_model", modelElement.name]},
2114
+ {$eq: ["$_user", user.username]}
2115
+ ]
2116
+ }
1783
2117
  }
1784
2118
  }
1785
- }
1786
- )
2119
+ );
2120
+ }
2121
+ }
2122
+ // --- END: Modified pipeline construction ---
2123
+
2124
+ // Intégration des pipelines personnalisés au début si nécessaire
2125
+ if (customPipelines.length > 0 && pipelinesPosition === 'start') {
2126
+ pipelines = pipelines.concat(customPipelines);
1787
2127
  }
1788
2128
 
1789
2129
  pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, []));
2130
+
2131
+ // Intégration des pipelines personnalisés à la fin si nécessaire
2132
+ if (customPipelines.length > 0 && pipelinesPosition !== 'start') {
2133
+ pipelines = pipelines.concat(customPipelines);
2134
+ }
2135
+
1790
2136
  if (depthParam) {
1791
2137
  pipelines.push({$project: {_user: 0}});
1792
2138
  pipelines.push({$project: {_model: 0}});
1793
2139
  }
1794
2140
 
1795
- //console.log(util.inspect(pipelines, false, 29, true));
1796
-
1797
- // 4. Exécuter la pipeline
1798
- const ts = parseInt(timeout, 10) / 2.0 || searchRequestTimeout;
2141
+ const mt = Config.Get('searchRequestTimeout', searchRequestTimeout);
2142
+ const ts = parseInt(timeout, 10) / 2.0 || mt;
1799
2143
  const count = await collection.aggregate([...pipelines, {$count: "count"}]).maxTimeMS(ts).toArray();
1800
2144
  let prom = collection.aggregate(pipelines).maxTimeMS(ts);
1801
2145
 
1802
- if (Object.keys(sortObj).length > 0) {
2146
+ // Apply sort logic:
2147
+ // 1. If a user-defined sort exists, use it.
2148
+ // 2. If not, and if there's no $geoNear stage (which has implicit sort), apply a default sort.
2149
+ if (sortObj) {
1803
2150
  prom.sort(sortObj);
2151
+ } else if (!pipelines.some(stage => stage.$geoNear)) {
2152
+ const defaultSort = { [modelElement.fields[0]?.name || '_id']: ['datetime', 'date'].includes(modelElement.fields[0].type) ? -1 : 1 };
2153
+ prom.sort(defaultSort);
1804
2154
  }
1805
2155
  prom.skip(p ? (p - 1) * l : 0).limit(l);
1806
2156
  let data = await prom.toArray();
1807
2157
  data = await handleFields(modelElement, data, user);
1808
2158
 
1809
2159
  const res = {data, count: count[0]?.count || 0};
2160
+
2161
+ // Mettre en cache le résultat
2162
+ searchCache.set(cacheKey, res);
2163
+
2164
+ // Stocker les clés de cache associées aux modèles et IDs pour l'invalidation
2165
+ storeCacheRelations(cacheKey, model, data.map(item => item._id.toString()));
2166
+
1810
2167
  const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
1811
2168
  await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
1812
2169
  return plugin || res;
1813
2170
  }
2171
+
2172
+
2173
+ // Map pour stocker les relations entre les données et les clés de cache
2174
+ const cacheRelations = new Map();
2175
+
2176
+ // Stocker les relations cache-données
2177
+ const storeCacheRelations = (cacheKey, model, dataIds) => {
2178
+ const relationKey = `model:${model}`;
2179
+
2180
+ if (!cacheRelations.has(relationKey)) {
2181
+ cacheRelations.set(relationKey, new Set());
2182
+ }
2183
+
2184
+ const modelCacheKeys = cacheRelations.get(relationKey);
2185
+ modelCacheKeys.add(cacheKey);
2186
+
2187
+ // Stocker aussi les relations par ID individuel
2188
+ dataIds.forEach(id => {
2189
+ const idKey = `id:${model}:${id}`;
2190
+ if (!cacheRelations.has(idKey)) {
2191
+ cacheRelations.set(idKey, new Set());
2192
+ }
2193
+ cacheRelations.get(idKey).add(cacheKey);
2194
+ });
2195
+ };
2196
+
2197
+ // Invalider le cache pour un modèle spécifique
2198
+ export const invalidateModelCache = (model) => {
2199
+ const relationKey = `model:${model}`;
2200
+
2201
+ if (cacheRelations.has(relationKey)) {
2202
+ const cacheKeys = cacheRelations.get(relationKey);
2203
+ cacheKeys.forEach(key => searchCache.del(key));
2204
+ cacheRelations.delete(relationKey);
2205
+
2206
+ console.log(`Invalidated cache for model: ${model}`);
2207
+ }
2208
+ };
2209
+
2210
+ /**
2211
+ * Vide entièrement le cache de recherche. Utile lors de la modification de la structure d'un modèle.
2212
+ */
2213
+ export const flushSearchCache = () => {
2214
+ const stats = searchCache.getStats();
2215
+ searchCache.flushAll();
2216
+ cacheRelations.clear();
2217
+ logger.info(`[Cache] Flushed all search cache. Removed ${stats.keys} keys.`);
2218
+ };
2219
+ // Invalider le cache pour des IDs spécifiques
2220
+ const invalidateIdsCache = (model, ids) => {
2221
+ if (!Array.isArray(ids)) {
2222
+ ids = [ids];
2223
+ }
2224
+
2225
+ ids.forEach(id => {
2226
+ const idKey = `id:${model}:${id}`;
2227
+
2228
+ if (cacheRelations.has(idKey)) {
2229
+ const cacheKeys = cacheRelations.get(idKey);
2230
+ cacheKeys.forEach(key => searchCache.del(key));
2231
+ cacheRelations.delete(idKey);
2232
+
2233
+ console.log(`Invalidated cache for ID: ${id} in model: ${model}`);
2234
+ }
2235
+ });
2236
+
2237
+ // Invalider aussi le cache du modèle entier (optionnel, plus agressif)
2238
+ // invalidateModelCache(model);
2239
+ };
2240
+
2241
+
1814
2242
  export const importData = async (options, files, user) => {
1815
2243
 
1816
2244
  if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)) {
@@ -2384,7 +2812,21 @@ export async function installPack(packIdentifier, user = null, lang = 'en', opti
2384
2812
  } else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
2385
2813
  // New behavior - use provided pack object directly
2386
2814
  pack = packIdentifier;
2815
+ let totalEntries = 0;
2816
+ for (const langKey in pack) {
2817
+ const langData = pack[langKey];
2818
+ for (const modelKey in langData) {
2819
+ if (Array.isArray(langData[modelKey])) {
2820
+ totalEntries += langData[modelKey].length;
2821
+ }
2822
+ }
2823
+ }
2387
2824
 
2825
+ const m = Config.Get('maxPackData',maxPackData);
2826
+ if (totalEntries > m) {
2827
+ // Cette erreur sera retournée à l'utilisateur via l'API.
2828
+ throw new Error(`Pack installation failed: The pack contains ${totalEntries} data entries, which exceeds the limit of ${m}. Please split the pack into smaller ones.`);
2829
+ }
2388
2830
  // Validate basic pack structure
2389
2831
  if (!pack.name || (!pack.models && !pack.data)) {
2390
2832
  throw new Error('Invalid pack structure - must contain at least name and models or data');