data-primals-engine 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +484 -175
  3. package/client/package.json +7 -4
  4. package/client/src/AssistantChat.jsx +1 -3
  5. package/client/src/DataLayout.jsx +19 -20
  6. package/client/src/DataTable.jsx +2 -2
  7. package/client/src/DocumentationPageLayout.scss +1 -1
  8. package/client/src/ViewSwitcher.jsx +1 -1
  9. package/client/vite.config.js +31 -30
  10. package/package.json +27 -17
  11. package/src/ai.jobs.js +135 -0
  12. package/src/constants.js +561 -545
  13. package/src/data.js +2 -0
  14. package/src/engine.js +50 -42
  15. package/src/modules/assistant/assistant.js +782 -763
  16. package/src/modules/assistant/constants.js +23 -16
  17. package/src/modules/assistant/providers.js +77 -37
  18. package/src/modules/bucket.js +4 -0
  19. package/src/modules/data/data.cluster.js +191 -0
  20. package/src/modules/data/data.core.js +11 -8
  21. package/src/modules/data/data.js +311 -311
  22. package/src/modules/data/data.operations.js +186 -106
  23. package/src/modules/data/data.relations.js +1 -0
  24. package/src/modules/data/data.replication.js +83 -0
  25. package/src/modules/data/data.routes.js +2183 -1879
  26. package/src/modules/mongodb.js +76 -73
  27. package/src/modules/user.js +7 -1
  28. package/src/modules/worker-script-runner.js +97 -0
  29. package/src/modules/workflow.js +177 -52
  30. package/src/packs.js +5701 -5701
  31. package/src/providers.js +298 -297
  32. package/test/assistant.test.js +207 -206
  33. package/test/data.integration.test.js +1425 -1416
  34. package/test/import_export.integration.test.js +210 -210
  35. package/test/workflow.actions.integration.test.js +487 -475
  36. package/test/workflow.integration.test.js +332 -329
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getObjectHash,
3
- getRand,
3
+ getRand,
4
4
  getRandom,
5
5
  isPlainObject,
6
6
  parseSafeJSON,
@@ -26,7 +26,7 @@ import {
26
26
  } from "../../constants.js";
27
27
  import {anonymizeText, encryptValue, getFieldValueHash, getUserId, isDemoUser, isLocalUser} from "../../data.js";
28
28
  import sanitizeHtml from "sanitize-html";
29
- import {importJobs, modelsCache, runCryptoWorkerTask, runImportExportWorker} from "./data.core.js";
29
+ import {modelsCache, runCryptoWorkerTask, runImportExportWorker} from "./data.core.js";
30
30
  import {
31
31
  getCollection,
32
32
  getCollectionForUser,
@@ -50,7 +50,6 @@ import {validateModelData, validateModelStructure} from "./data.validation.js";
50
50
  import {addFile, removeFile} from "../file.js";
51
51
  import NodeCache from "node-cache";
52
52
  import fs from "node:fs";
53
- import readXlsxFile from "read-excel-file/node";
54
53
  import {checkServerCapacity} from "./data.js";
55
54
  import {Logger} from "../../gameObject.js";
56
55
  import {
@@ -64,6 +63,7 @@ import crypto from 'crypto';
64
63
  import cronstrue from 'cronstrue/i18n.js'
65
64
  import { isConditionMet } from '../../filter.js';
66
65
  import util from "node:util";
66
+ import {broadcastCacheInvalidation} from "./data.cluster.js";
67
67
 
68
68
  const delay = ms => new Promise(res => setTimeout(res, ms));
69
69
  const IMPORT_CHUNK_SIZE = 100; // Nombre d'enregistrements à traiter par lot
@@ -479,6 +479,43 @@ const userStorageCache = new NodeCache({
479
479
 
480
480
 
481
481
  let engine, logger;
482
+
483
+ /**
484
+ * Vérifie l'unicité d'une valeur sur l'ensemble du cluster.
485
+ * @param {string} modelName - Nom du modèle.
486
+ * @param {string} fieldName - Nom du champ unique.
487
+ * @param {*} value - Valeur à vérifier.
488
+ * @param {object} user - Objet utilisateur.
489
+ * @param {string} authToken - Token d'authentification pour les appels internes.
490
+ * @returns {Promise<void>} - Lève une erreur si la valeur n'est pas unique.
491
+ */
492
+ async function checkClusterUniqueness(modelName, fieldName, value, user, authToken) {
493
+ if (clusterPeers.length === 0) return; // Pas de cluster, pas de vérification distribuée.
494
+
495
+ logger.debug(`[Cluster] Checking uniqueness for ${modelName}.${fieldName} = ${value}`);
496
+
497
+ const checkPromises = clusterPeers.map(peerUrl => {
498
+ const url = new URL(`${peerUrl}/api/data/check-uniqueness`);
499
+ url.searchParams.append('model', modelName);
500
+ url.searchParams.append('field', fieldName);
501
+ url.searchParams.append('value', value);
502
+ url.searchParams.append('user', user.username);
503
+
504
+ return fetch(url.toString(), {
505
+ headers: { 'Authorization': authToken }
506
+ }).then(res => res.json());
507
+ });
508
+
509
+ const results = await Promise.all(checkPromises);
510
+
511
+ for (const result of results) {
512
+ if (result.exists) {
513
+ throw new Error(`La valeur '${value}' pour le champ '${fieldName}' existe déjà sur un autre nœud du cluster.`);
514
+ }
515
+ }
516
+ }
517
+
518
+
482
519
  export function onInit(defaultEngine) {
483
520
  engine = defaultEngine;
484
521
  logger = engine.getComponent(Logger);
@@ -703,8 +740,8 @@ export const getModel = async (modelName, user) => {
703
740
  }
704
741
  export const getModels = async () => {
705
742
  return await getCollection('models')?.find({'$or': [{_user: {$exists: false}}]}).toArray() || [];
706
- }
707
- export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true, checkPermissions = true) => {
743
+ }
744
+ export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true, checkPermissions = true, req = null) => {
708
745
 
709
746
  if (checkPermissions) {
710
747
  // --- Vérification des permissions ---
@@ -1130,6 +1167,16 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1130
1167
  };
1131
1168
  }
1132
1169
 
1170
+ // --- FIX: Handle case where filter is a simple ID string ---
1171
+ if (typeof filter === 'string' && isObjectId(filter)) {
1172
+ combinedFilter = { $and: [ { _id: new ObjectId(filter) }, permissionResult || {} ] };
1173
+ }
1174
+
1175
+ // Handle case where filter is an object like { _id: "..." }
1176
+ if (isPlainObject(filter) && filter._id && isObjectId(filter._id)) {
1177
+ combinedFilter = { $and: [ { _id: new ObjectId(filter._id) }, permissionResult || {} ] };
1178
+ }
1179
+
1133
1180
  // 3. Récupération des documents à modifier en utilisant le filtre combiné
1134
1181
  const docsToEditQuery = await searchData({model: modelName, filter: combinedFilter}, user);
1135
1182
  let existingDocs = docsToEditQuery.data;
@@ -1232,17 +1279,22 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1232
1279
  // 6. Vérification des champs uniques (s'applique uniquement aux opérations $set)
1233
1280
  const uniqueFields = model.fields.filter(f => f.unique);
1234
1281
  for (const field of uniqueFields) {
1235
- if (updateData[field.name] !== undefined) {
1282
+ if (!updateData.hasOwnProperty(field.name)) {
1283
+ continue;
1284
+ }
1285
+ const finalValue = updateData[field.name];
1286
+
1287
+ if (finalValue !== undefined && finalValue !== null && finalValue !== '') {
1236
1288
  const existing = await collection.findOne({
1237
1289
  _user: user._user || user.username,
1238
1290
  _model: modelName,
1239
- [field.name]: updateData[field.name],
1240
- _id: {$nin: ids}
1291
+ [field.name]: finalValue, // Cherche un document avec la nouvelle valeur...
1292
+ _id: {$ne: existingDocs[0]._id} // ...qui n'est PAS le document qu'on est en train de modifier.
1241
1293
  });
1242
1294
  if (existing) {
1243
1295
  throw new Error(i18n.t("api.data.duplicateValue", {
1244
1296
  field: field.name,
1245
- value: updateData[field.name]
1297
+ value: finalValue
1246
1298
  }));
1247
1299
  }
1248
1300
  }
@@ -1344,12 +1396,17 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1344
1396
  const sizeDelta = finalDocsSize - originalDocsSize;
1345
1397
 
1346
1398
  if (sizeDelta > 0) {
1347
- const userStorageLimit = await engine.userProvider.getUserStorageLimit(user);
1348
- const currentStorageUsage = await calculateTotalUserStorageUsage(user);
1349
- if (currentStorageUsage + sizeDelta > userStorageLimit) {
1350
- throw new Error(i18n.t("api.data.storageLimitExceeded", { limit: Math.round(userStorageLimit / megabytes) }));
1399
+ // --- MODIFICATION : Utiliser la nouvelle limite de stockage de la base de données ---
1400
+ const dbStorageLimit = Config.Get('maxTotalDataSizePerUser'); // Récupère la limite en octets
1401
+
1402
+ if (dbStorageLimit > 0) {
1403
+ const currentStorageUsage = await calculateTotalUserStorageUsage(user);
1404
+ if (currentStorageUsage.database + sizeDelta > dbStorageLimit) {
1405
+ throw new Error(i18n.t("api.data.storageLimitExceeded", { limit: Math.round(dbStorageLimit / megabytes) }));
1406
+ }
1351
1407
  }
1352
- const serverCapacity = await checkServerCapacity(sizeDelta);
1408
+ // --- FIN MODIFICATION ---
1409
+ const serverCapacity = await checkServerCapacity(sizeDelta); // La vérification de la capacité serveur reste pertinente
1353
1410
  if (!serverCapacity.isSufficient) {
1354
1411
  throw new Error(i18n.t("api.data.serverStorageFull"));
1355
1412
  }
@@ -1377,9 +1434,20 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1377
1434
  }
1378
1435
  const newHash = getFieldValueHash(model, finalStateForHash);
1379
1436
 
1437
+ // 11. *** CORRECTION LOGIQUE ***
1438
+ // On ne vérifie l'unicité que si le hash a réellement changé.
1439
+ if (newHash !== originalHash) {
1440
+ const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
1441
+ if (hashCheck) {
1442
+ // Le nouvel état du document créerait un doublon.
1443
+ throw new Error(i18n.t("api.data.notUniqueData"));
1444
+ }
1445
+ }
1446
+
1380
1447
  // Préparation de l'objet de mise à jour final pour MongoDB
1381
1448
  const finalUpdateOperation = {};
1382
1449
  if (Object.keys(updateSetData).length > 0) {
1450
+ updateSetData._lastModifiedAt = new Date(); // Ajout du timestamp
1383
1451
  finalUpdateOperation.$set = { ...updateSetData, _hash: newHash };
1384
1452
  }
1385
1453
  if (Object.keys(updatePushData).length > 0) {
@@ -1388,16 +1456,7 @@ const internalEditOrPatchData = async (modelName, filter, data, files, user, isP
1388
1456
  if (!finalUpdateOperation.$set) {
1389
1457
  finalUpdateOperation.$set = { _hash: newHash };
1390
1458
  }
1391
- }
1392
-
1393
- // 11. *** CORRECTION LOGIQUE ***
1394
- // On ne vérifie l'unicité que si le hash a réellement changé.
1395
- if (newHash !== originalHash) {
1396
- const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
1397
- if (hashCheck) {
1398
- // Le nouvel état du document créerait un doublon.
1399
- throw new Error(i18n.t("api.data.notUniqueData"));
1400
- }
1459
+ finalUpdateOperation.$set._lastModifiedAt = new Date(); // Ajout du timestamp
1401
1460
  }
1402
1461
 
1403
1462
  // 12. Exécution de la mise à jour.
@@ -1559,11 +1618,11 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1559
1618
  // --- AMÉLIORATION ---
1560
1619
  // On détermine le nom du modèle à partir du premier document trouvé.
1561
1620
  // C'est crucial car la fonction peut être appelée sans `modelName` explicite (ex: via /api/data/:ids).
1562
- // On suppose que toutes les suppressions dans un même appel concernent le même modèle.
1621
+ // On suppose que toutes les suppressions dans un même appel concernent le même modèle, ce qui est le cas ici.
1563
1622
  const modelNameToInvalidate = modelName || documentsToDelete[0]?._model;
1564
1623
 
1565
1624
  const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
1566
- const idsToInvalidate = []; // *** AJOUT: IDs à invalider dans le cache ***
1625
+ const idsToInvalidate = [];
1567
1626
 
1568
1627
  for (const docToDelete of documentsToDelete) {
1569
1628
  const deletePromises = [];
@@ -1585,7 +1644,6 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1585
1644
  }
1586
1645
  }
1587
1646
  if (deletePromises.length > 0) {
1588
- await Promise.allSettled(deletePromises);
1589
1647
  }
1590
1648
 
1591
1649
  // *** Ajout de l'annulation du schedule pour workflowTrigger ***
@@ -1700,7 +1758,7 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1700
1758
  finalIdsToDelete.push(docToDelete._id);
1701
1759
  idsToInvalidate.push(docToDelete._id.toString());
1702
1760
  } // Fin de la boucle sur documentsToDelete
1703
-
1761
+
1704
1762
  // 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
1705
1763
  let deletedCount = 0;
1706
1764
  if (finalIdsToDelete.length > 0) {
@@ -2520,6 +2578,9 @@ export const invalidateModelCache = (model) => {
2520
2578
  cacheRelations.delete(relationKey);
2521
2579
 
2522
2580
  console.log(`Invalidated cache for model: ${model}`);
2581
+
2582
+ // Broadcast this invalidation to other nodes in the cluster
2583
+ broadcastCacheInvalidation('model', model);
2523
2584
  }
2524
2585
  };
2525
2586
 
@@ -2567,21 +2628,26 @@ export const importData = async (options, files, user) => {
2567
2628
  return ({success: false, error: "No file uploaded."});
2568
2629
  }
2569
2630
 
2570
- const importJobId = new ObjectId().toString();
2571
- importJobs[importJobId] = {
2631
+ const importJobsCollection = getCollection('import_jobs');
2632
+ // Créer un index TTL qui supprime les tâches 1h après leur dernière mise à jour.
2633
+ await importJobsCollection.createIndex({ "updatedAt": 1 }, { expireAfterSeconds: 3600 });
2634
+
2635
+ const importJobData = {
2572
2636
  userId: user.username,
2573
2637
  status: 'pending',
2574
2638
  totalRecords: 0,
2575
2639
  processedRecords: 0,
2576
2640
  errors: [],
2577
- jobId: importJobId // Inclure l'ID de la tâche dans son état
2641
+ createdAt: new Date(),
2642
+ updatedAt: new Date()
2578
2643
  };
2644
+ const insertResult = await importJobsCollection.insertOne(importJobData);
2645
+ const importJobId = insertResult.insertedId;
2579
2646
 
2580
- const importJob = importJobs[importJobId];
2581
2647
  // Excuter le reste de la logique d'importation en arrière-plan
2582
2648
  (async () => {
2583
2649
  let fileProcessed = false;
2584
- const importResults = {success: true, counts: {}, errors: []}; // Pour collecter les erreurs internes
2650
+ const importResults = { success: true, counts: {}, errors: [] };
2585
2651
 
2586
2652
  try {
2587
2653
  const fileContent = fs.readFileSync(file.path);
@@ -2676,8 +2742,9 @@ export const importData = async (options, files, user) => {
2676
2742
  // --- DÉBUT DE LA MODIFICATION PRINCIPALE ---
2677
2743
  // Mettre à jour le statut et le nombre total d'enregistrements avant la boucle
2678
2744
  if (importResults.success && modelsToProcess.length > 0) {
2679
- importJobs[importJobId].totalRecords = modelsToProcess.reduce((acc, model) => acc + model.data.length, 0);
2680
- importJobs[importJobId].status = 'processing';
2745
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2746
+ $set: { totalRecords: modelsToProcess.reduce((acc, model) => acc + model.data.length, 0), status: 'processing', updatedAt: new Date() }
2747
+ });
2681
2748
  }
2682
2749
 
2683
2750
  // Boucler sur CHAQUE modèle trouvé dans le fichier JSON
@@ -2706,11 +2773,12 @@ export const importData = async (options, files, user) => {
2706
2773
  try {
2707
2774
  const insertedIdsArray = await pushDataUnsecure(chunk, modelName, user, {});
2708
2775
  if (insertedIdsArray && insertedIdsArray.length > 0) {
2709
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
2710
- logger.debug(`[Import Job ${importJobId}] Processed chunk for '${modelName}': ${insertedIdsArray.length} records. Total processed: ${importJobs[importJobId].processedRecords}`);
2776
+ const updateResult = await importJobsCollection.findOneAndUpdate({ _id: importJobId }, {
2777
+ $inc: { processedRecords: insertedIdsArray.length },
2778
+ $set: { updatedAt: new Date() }
2779
+ }, { returnDocument: 'after' });
2711
2780
  sendSseToUser(user.username, {
2712
- type: 'import_progress',
2713
- job: importJobs[importJobId]
2781
+ type: 'import_progress', job: updateResult
2714
2782
  });
2715
2783
  }
2716
2784
  } catch (chunkError) {
@@ -2718,7 +2786,9 @@ export const importData = async (options, files, user) => {
2718
2786
  const errorMsg = `[Import Job ${importJobId}] Error on chunk for model '${modelName}': ${chunkError.message}`;
2719
2787
  logger.error(errorMsg);
2720
2788
  importResults.errors.push(errorMsg);
2721
- importJobs[importJobId].errors.push(errorMsg);
2789
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2790
+ $push: { errors: errorMsg }, $set: { updatedAt: new Date() }
2791
+ });
2722
2792
  importResults.success = false;
2723
2793
  }
2724
2794
 
@@ -2732,7 +2802,9 @@ export const importData = async (options, files, user) => {
2732
2802
  const errorMsg = `[Import Job ${importJobId}] Failed to process model '${modelName}': ${modelProcessingError.message}`;
2733
2803
  logger.error(errorMsg);
2734
2804
  importResults.errors.push(errorMsg);
2735
- importJobs[importJobId].errors.push(errorMsg);
2805
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2806
+ $push: { errors: errorMsg }, $set: { updatedAt: new Date() }
2807
+ });
2736
2808
  importResults.success = false;
2737
2809
  }
2738
2810
  }
@@ -2762,65 +2834,57 @@ export const importData = async (options, files, user) => {
2762
2834
  options: {columns: hasHeaders}
2763
2835
  });
2764
2836
 
2765
- let datasToImport;
2766
- if (!hasHeaders) {
2767
- const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
2837
+ const datasToImport = records.map(recordRow => {
2838
+ if (hasHeaders) {
2839
+ // If headers are present, the parser returns objects directly.
2840
+ return recordRow;
2841
+ }
2842
+ // If no headers, map array values to fields.
2843
+ const obj = {};
2844
+ const effectiveHeaders = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h))
2768
2845
  ? userDefinedHeadersForMapping
2769
2846
  : modelDef.fields.map(f => f.name);
2770
2847
 
2771
- datasToImport = records.map(recordRow => {
2772
- const obj = {};
2773
- if (Array.isArray(recordRow)) {
2774
- recordRow.forEach((value, index) => {
2775
- const targetModelFieldName = effectiveHeadersForMapping[index];
2776
- if (targetModelFieldName && targetModelFieldName !== '') {
2777
- if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
2778
- obj[targetModelFieldName] = value;
2779
- } else {
2780
- logger.warn(`CSV Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
2781
- }
2782
- }
2783
- });
2784
- } else {
2785
- Object.values(recordRow).forEach((value, index) => {
2786
- const targetModelFieldName = effectiveHeadersForMapping[index];
2787
- if (targetModelFieldName && targetModelFieldName !== '') {
2788
- if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
2789
- obj[targetModelFieldName] = value;
2790
- } else {
2791
- logger.warn(`CSV Import (!hasHeaders, object row): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
2792
- }
2793
- }
2794
- });
2848
+ Object.values(recordRow).forEach((value, index) => {
2849
+ const fieldName = effectiveHeaders[index];
2850
+ if (fieldName) {
2851
+ obj[fieldName] = value;
2795
2852
  }
2796
- return obj;
2797
2853
  });
2798
- } else {
2799
- datasToImport = records;
2854
+ return obj;
2855
+ });
2856
+
2857
+ if (datasToImport.length === 0) {
2858
+ throw new Error("No data could be parsed from the CSV file.");
2800
2859
  }
2801
2860
  const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'csv');
2802
2861
 
2803
2862
  // Logique de découpage en lots pour le CSV
2804
2863
  if (allProcessedData.length > 0) {
2805
- importJobs[importJobId].totalRecords = allProcessedData.length;
2806
- importJobs[importJobId].status = 'processing';
2864
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2865
+ $set: { totalRecords: allProcessedData.length, status: 'processing', updatedAt: new Date() }
2866
+ });
2807
2867
 
2808
2868
  for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
2809
2869
  const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
2810
2870
  try {
2811
2871
  const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
2812
2872
  if (insertedIdsArray && insertedIdsArray.length > 0) {
2813
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
2873
+ const updateResult = await importJobsCollection.findOneAndUpdate({ _id: importJobId }, {
2874
+ $inc: { processedRecords: insertedIdsArray.length },
2875
+ $set: { updatedAt: new Date() }
2876
+ }, { returnDocument: 'after' });
2814
2877
  sendSseToUser(user.username, {
2815
- type: 'import_progress',
2816
- job: importJobs[importJobId]
2878
+ type: 'import_progress', job: updateResult
2817
2879
  });
2818
2880
  }
2819
2881
  } catch (chunkError) {
2820
2882
  const errorMsg = `[Import Job ${importJobId}] Error on CSV chunk: ${chunkError.message}`;
2821
2883
  logger.error(errorMsg, chunkError.stack);
2822
2884
  importResults.errors.push(errorMsg);
2823
- importJobs[importJobId].errors.push(errorMsg);
2885
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2886
+ $push: { errors: errorMsg }, $set: { updatedAt: new Date() }
2887
+ });
2824
2888
  importResults.success = false;
2825
2889
  }
2826
2890
  if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
@@ -2834,7 +2898,9 @@ export const importData = async (options, files, user) => {
2834
2898
  logger.error(`[Import CSV] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
2835
2899
  importResults.errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
2836
2900
  importResults.success = false;
2837
- importJobs[importJobId].errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
2901
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2902
+ $push: { errors: `Model ${modelNameForImport} (CSV): ${modelProcessingError.message}` }, $set: { updatedAt: new Date() }
2903
+ });
2838
2904
  }
2839
2905
  }
2840
2906
  } else if (['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type) || file.name.endsWith('.xls') || file.name.endsWith('.xlsx')) {
@@ -2883,37 +2949,45 @@ export const importData = async (options, files, user) => {
2883
2949
  });
2884
2950
 
2885
2951
  if (excelErrors.length > 0) {
2886
- excelErrors.forEach(error => {
2952
+ for(let i = 0; i < excelErrors.length;++i){
2953
+ const error = excelErrors[i];
2887
2954
  const errorMsg = `Excel Import Error (Row ${error.row}, Column "${error.column}"): ${error.error}.`;
2888
2955
  logger.error(`[Import Job ${importJobId}] ${errorMsg}`);
2889
2956
  importResults.errors.push(errorMsg);
2890
- importJobs[importJobId].errors.push(errorMsg);
2891
- });
2957
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2958
+ $push: { errors: errorMsg }, $set: { updatedAt: new Date() }
2959
+ });
2960
+ }
2892
2961
  importResults.success = false;
2893
2962
  }
2894
2963
 
2895
2964
  if (datasToImport && datasToImport.length > 0) {
2896
2965
  const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'excel');
2897
2966
 
2898
- importJobs[importJobId].totalRecords = allProcessedData.length;
2899
- importJobs[importJobId].status = 'processing';
2967
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2968
+ $set: { totalRecords: allProcessedData.length, status: 'processing', updatedAt: new Date() }
2969
+ });
2900
2970
 
2901
2971
  for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
2902
2972
  const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
2903
2973
  try {
2904
2974
  const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
2905
2975
  if (insertedIdsArray && insertedIdsArray.length > 0) {
2906
- importJobs[importJobId].processedRecords += insertedIdsArray.length;
2976
+ const updateResult = await importJobsCollection.findOneAndUpdate({ _id: importJobId }, {
2977
+ $inc: { processedRecords: insertedIdsArray.length },
2978
+ $set: { updatedAt: new Date() }
2979
+ }, { returnDocument: 'after' });
2907
2980
  sendSseToUser(user.username, {
2908
- type: 'import_progress',
2909
- job: importJobs[importJobId]
2981
+ type: 'import_progress', job: updateResult
2910
2982
  });
2911
2983
  }
2912
2984
  } catch (chunkError) {
2913
2985
  const errorMsg = `[Import Job ${importJobId}] Error on Excel chunk: ${chunkError.message}`;
2914
2986
  logger.error(errorMsg, chunkError.stack);
2915
2987
  importResults.errors.push(errorMsg);
2916
- importJobs[importJobId].errors.push(errorMsg);
2988
+ await importJobsCollection.updateOne({ _id: importJobId }, {
2989
+ $push: { errors: errorMsg }, $set: { updatedAt: new Date() }
2990
+ });
2917
2991
  importResults.success = false;
2918
2992
  }
2919
2993
  if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) await delay(IMPORT_CHUNK_DELAY_MS);
@@ -2925,34 +2999,38 @@ export const importData = async (options, files, user) => {
2925
2999
  logger.error(`[Import Excel] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
2926
3000
  importResults.errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
2927
3001
  importResults.success = false;
2928
- importJobs[importJobId].errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
3002
+ await importJobsCollection.updateOne({ _id: importJobId }, {
3003
+ $push: { errors: `Model ${modelNameForImport} (Excel): ${modelProcessingError.message}` }, $set: { updatedAt: new Date() }
3004
+ });
2929
3005
  }
2930
3006
  }
2931
3007
  } else {
2932
3008
  importResults.errors.push("Unsupported file type. Please upload a JSON or CSV file.");
2933
3009
  importResults.success = false;
2934
- importJobs[importJobId].errors.push("Unsupported file type. Please upload a JSON or CSV file.");
3010
+ await importJobsCollection.updateOne({ _id: importJobId }, {
3011
+ $push: { errors: "Unsupported file type. Please upload a JSON or CSV file." }, $set: { updatedAt: new Date() }
3012
+ });
2935
3013
  }
2936
3014
 
2937
3015
  } catch (e) {
2938
3016
  logger.error("Import Error (Global):", e);
2939
3017
  importResults.success = false;
2940
3018
  importResults.errors.push(e.message || "An unexpected error occurred during import.");
2941
- if (importJobs[importJobId]) {
2942
- importJobs[importJobId].errors.push(e.message || "An unexpected error occurred during import.");
2943
- }
3019
+ await importJobsCollection.updateOne({ _id: importJobId }, {
3020
+ $push: { errors: e.message || "An unexpected error occurred during import." }, $set: { updatedAt: new Date() }
3021
+ });
2944
3022
  } finally {
2945
- if (importJobs[importJobId]) {
2946
- if (importResults.errors.length > 0) {
2947
- importJobs[importJobId].status = 'failed';
2948
- } else {
2949
- importJobs[importJobId].status = 'completed';
2950
- }
3023
+ const finalStatus = importResults.errors.length > 0 ? 'failed' : 'completed';
3024
+ const finalJobState = await importJobsCollection.findOneAndUpdate({ _id: importJobId }, {
3025
+ $set: { status: finalStatus, updatedAt: new Date() }
3026
+ }, { returnDocument: 'after' });
3027
+
3028
+ if (finalJobState) {
2951
3029
  sendSseToUser(user.username, {
2952
- type: 'import_progress',
2953
- job: importJobs[importJobId]
3030
+ type: 'import_progress', job: finalJobState
2954
3031
  });
2955
3032
  }
3033
+
2956
3034
  if (file && file.path && fs.existsSync(file.path)) {
2957
3035
  fs.unlinkSync(file.path);
2958
3036
  }
@@ -2960,15 +3038,17 @@ export const importData = async (options, files, user) => {
2960
3038
  })().catch(error => {
2961
3039
  logger.error(`Unhandled error in background import job ${importJobId}:`, error);
2962
3040
  if (importJobs[importJobId]) {
2963
- importJobs[importJobId].status = 'failed';
2964
- importJobs[importJobId].errors.push(error.message || "An unhandled error occurred in background process.");
2965
- sendSseToUser(user.username, {
2966
- type: 'import_progress',
2967
- job: importJobs[importJobId]
3041
+ importJobsCollection.findOneAndUpdate({ _id: importJobId }, {
3042
+ $set: { status: 'failed', updatedAt: new Date() },
3043
+ $push: { errors: error.message || "An unhandled error occurred in background process." }
3044
+ }, { returnDocument: 'after' }).then(finalJobState => {
3045
+ if (finalJobState) {
3046
+ sendSseToUser(user.username, { type: 'import_progress', job: finalJobState });
3047
+ }
2968
3048
  });
2969
3049
  }
2970
3050
  });
2971
- return ({success: true, message: "Import initiated. Check progress via SSE.", job: importJob});
3051
+ return ({ success: true, message: "Import initiated. Check progress via SSE.", jobId: importJobId.toString() });
2972
3052
  }
2973
3053
  export const exportData = async (options, user) => {
2974
3054
  // Extract parameters from request body and query
@@ -536,6 +536,7 @@ function prepareDocument(doc, model, me) {
536
536
  docToProcess._model = model.name;
537
537
  docToProcess._user = me.username || me._user;
538
538
  docToProcess._hash = getFieldValueHash(model, docToProcess);
539
+ docToProcess._lastModifiedAt = new Date(); // Ajout systématique du timestamp
539
540
 
540
541
  return docToProcess;
541
542
  }
@@ -0,0 +1,83 @@
1
+ // --- NOUVEAU FICHIER : src/modules/data/data.replication.js ---
2
+ import { Event } from '../../events.js';
3
+ import { Logger } from '../../gameObject.js';
4
+ import { getResponsibleNodesForUser, isSelfMasterForUser } from './data.cluster.js';
5
+
6
+ let logger;
7
+
8
+ /**
9
+ * Réplique une opération de données vers les nœuds répliques.
10
+ * @param {string} operation - 'insert', 'update', 'delete'
11
+ * @param {string} modelName - Le nom du modèle.
12
+ * @param {object} user - L'objet utilisateur.
13
+ * @param {object} payload - Les données de l'opération.
14
+ */
15
+ async function replicateOperation(operation, modelName, user, payload) {
16
+ if (!isSelfMasterForUser(user.username)) {
17
+ // Seul le nœud maître est autorisé à initier une réplication.
18
+ return;
19
+ }
20
+
21
+ const nodes = getResponsibleNodesForUser(user.username);
22
+ const replicas = nodes.slice(1); // Tous les nœuds sauf le premier (le maître)
23
+
24
+ if (replicas.length === 0) {
25
+ return; // Pas de répliques à notifier.
26
+ }
27
+
28
+ logger.debug(`[Replication] Replicating '${operation}' on model '${modelName}' for user '${user.username}' to replicas: ${replicas.join(', ')}`);
29
+
30
+ const replicationPayload = {
31
+ operation,
32
+ modelName,
33
+ user: { username: user.username, _user: user._user }, // Envoyer juste l'essentiel
34
+ payload
35
+ };
36
+
37
+ const promises = replicas.map(replicaUrl => {
38
+ const targetUrl = `${replicaUrl}/api/internal/replicate`;
39
+ const authToken = process.env.INTERNAL_CLUSTER_TOKEN ? `Bearer ${process.env.INTERNAL_CLUSTER_TOKEN}` : null;
40
+
41
+ return fetch(targetUrl, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Authorization': authToken
46
+ },
47
+ body: JSON.stringify(replicationPayload)
48
+ }).catch(err => {
49
+ logger.error(`[Replication] Failed to replicate to ${replicaUrl}: ${err.message}`);
50
+ });
51
+ });
52
+
53
+ // On ne bloque pas la réponse à l'utilisateur, la réplication est asynchrone.
54
+ Promise.allSettled(promises);
55
+ }
56
+
57
+ export function onInit(engine) {
58
+ logger = engine.getComponent(Logger);
59
+
60
+ // Écouter les événements de modification de données
61
+ Event.Listen("OnDataAdded", (eng, { modelName, insertedDocs, user }) => {
62
+ replicateOperation('insert', modelName, user, { data: insertedDocs });
63
+ }, "event", "system");
64
+
65
+ Event.Listen("OnDataEdited", (eng, { modelName, user, after }) => {
66
+ // Pour une mise à jour, on a besoin du filtre et des données modifiées.
67
+ // C'est un peu plus complexe car l'événement ne fournit pas le filtre original.
68
+ // Pour une réplication simple, on peut envoyer l'ID et le payload de mise à jour.
69
+ after.forEach(doc => {
70
+ replicateOperation('update', modelName, user, {
71
+ filter: { _id: doc._id.toString() },
72
+ data: doc // Envoyer le document complet est plus simple pour la réplique.
73
+ });
74
+ });
75
+ }, "event", "system");
76
+
77
+ Event.Listen("OnDataDeleted", (eng, { modelName, user, before }) => {
78
+ const idsToDelete = before.map(doc => doc._id.toString());
79
+ replicateOperation('delete', modelName, user, { ids: idsToDelete });
80
+ }, "event", "system");
81
+
82
+ logger.info("Replication module initialized and listening for data events.");
83
+ }