data-primals-engine 1.7.0 → 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.
- package/README.md +160 -160
- package/client/package-lock.json +1080 -212
- package/client/package.json +12 -6
- package/client/src/AssistantChat.jsx +1 -3
- package/client/src/DataLayout.jsx +19 -20
- package/client/src/DataTable.jsx +2 -2
- package/client/src/DocumentationPageLayout.scss +1 -1
- package/client/src/ViewSwitcher.jsx +1 -1
- package/client/vite.config.js +31 -30
- package/package.json +39 -23
- package/src/ai.jobs.js +135 -0
- package/src/constants.js +561 -545
- package/src/core.js +487 -477
- package/src/data.js +2 -0
- package/src/email.js +0 -2
- package/src/engine.js +50 -42
- package/src/filter.js +348 -343
- package/src/modules/assistant/assistant.js +782 -763
- package/src/modules/assistant/constants.js +23 -16
- package/src/modules/assistant/providers.js +77 -37
- package/src/modules/bucket.js +4 -0
- package/src/modules/data/data.cluster.js +191 -0
- package/src/modules/data/data.core.js +11 -8
- package/src/modules/data/data.js +13 -4
- package/src/modules/data/data.operations.js +186 -106
- package/src/modules/data/data.relations.js +1 -0
- package/src/modules/data/data.replication.js +83 -0
- package/src/modules/data/data.routes.js +2183 -1879
- package/src/modules/mongodb.js +76 -73
- package/src/modules/user.js +7 -1
- package/src/modules/worker-script-runner.js +97 -0
- package/src/modules/workflow.js +1953 -1815
- package/src/packs.js +5701 -5697
- package/src/providers.js +298 -297
- package/test/assistant.test.js +207 -206
- package/test/data.integration.test.js +1425 -1416
- package/test/import_export.integration.test.js +210 -210
- package/test/workflow.actions.integration.test.js +487 -475
- 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 {
|
|
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
|
|
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]:
|
|
1240
|
-
_id: {$
|
|
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:
|
|
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
|
-
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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 = [];
|
|
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
|
|
2571
|
-
|
|
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
|
-
|
|
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: []};
|
|
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
|
-
|
|
2680
|
-
|
|
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
|
-
|
|
2710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
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
|
-
|
|
2772
|
-
const
|
|
2773
|
-
if (
|
|
2774
|
-
|
|
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
|
-
|
|
2799
|
-
|
|
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
|
-
|
|
2806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2942
|
-
|
|
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
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
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
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
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.",
|
|
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
|
+
}
|