data-primals-engine 1.5.1 → 1.6.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 (70) hide show
  1. package/README.md +924 -913
  2. package/client/README.md +136 -136
  3. package/client/index.js +0 -1
  4. package/client/public/doc/API.postman_collection.json +213 -213
  5. package/client/public/react.svg +9 -9
  6. package/client/src/AddWidgetTypeModal.jsx +47 -47
  7. package/client/src/App.css +42 -42
  8. package/client/src/App.jsx +92 -150
  9. package/client/src/App.scss +7 -1
  10. package/client/src/AssistantChat.jsx +362 -363
  11. package/client/src/Button.jsx +12 -12
  12. package/client/src/Dashboard.jsx +480 -480
  13. package/client/src/DashboardHtmlViewItem.jsx +146 -146
  14. package/client/src/DashboardView.jsx +654 -654
  15. package/client/src/DataEditor.jsx +383 -383
  16. package/client/src/DataLayout.jsx +779 -806
  17. package/client/src/DataTable.jsx +782 -822
  18. package/client/src/DataTable.scss +186 -186
  19. package/client/src/GeolocationField.jsx +93 -93
  20. package/client/src/HistoryDialog.jsx +307 -285
  21. package/client/src/HistoryDialog.scss +319 -319
  22. package/client/src/HtmlViewBuilderModal.jsx +90 -90
  23. package/client/src/HtmlViewBuilderModal.scss +17 -17
  24. package/client/src/HtmlViewCard.jsx +43 -43
  25. package/client/src/ModelCreatorField.jsx +950 -950
  26. package/client/src/ModelList.jsx +7 -2
  27. package/client/src/Notification.jsx +136 -136
  28. package/client/src/PackGallery.jsx +391 -391
  29. package/client/src/PackGallery.scss +231 -231
  30. package/client/src/Pagination.jsx +143 -143
  31. package/client/src/RelationField.jsx +354 -354
  32. package/client/src/RelationSelectorWidget.jsx +172 -172
  33. package/client/src/RelationValue.jsx +2 -1
  34. package/client/src/contexts/CommandContext.jsx +260 -0
  35. package/client/src/contexts/ModelContext.jsx +4 -1
  36. package/client/src/contexts/UIContext.jsx +72 -72
  37. package/client/src/filter.js +263 -262
  38. package/client/src/index.css +90 -90
  39. package/package.json +11 -10
  40. package/perf/artillery-hooks.js +37 -37
  41. package/perf/setup.yml +25 -25
  42. package/src/constants.js +4 -0
  43. package/src/core.js +8 -1
  44. package/src/engine.js +335 -293
  45. package/src/events.js +140 -21
  46. package/src/filter.js +274 -274
  47. package/src/index.js +3 -0
  48. package/src/modules/assistant/assistant.js +323 -192
  49. package/src/modules/assistant/constants.js +2 -1
  50. package/src/modules/auth-google/index.js +50 -50
  51. package/src/modules/auth-microsoft/index.js +81 -81
  52. package/src/modules/data/data.core.js +118 -118
  53. package/src/modules/data/data.history.js +555 -531
  54. package/src/modules/data/data.operations.js +145 -26
  55. package/src/modules/data/data.relations.js +686 -686
  56. package/src/modules/data/data.routes.js +1879 -1879
  57. package/src/modules/data/data.validation.js +2 -2
  58. package/src/modules/file.js +247 -247
  59. package/src/modules/user.js +1 -0
  60. package/src/modules/workflow.js +2 -2
  61. package/src/openai.jobs.js +3 -2
  62. package/src/packs.js +2 -2
  63. package/src/providers.js +2 -2
  64. package/src/services/stripe.js +196 -196
  65. package/src/sso.js +193 -193
  66. package/src/workers/import-export-worker.js +1 -1
  67. package/test/data.history.integration.test.js +72 -0
  68. package/test/data.integration.test.js +92 -1
  69. package/test/events.test.js +108 -10
  70. package/test/model.integration.test.js +377 -377
@@ -1,532 +1,556 @@
1
- import {isPlainObject, safeAssignObject} from "../../core.js";
2
- import {getCollection, getCollectionForUser, isObjectId, ObjectId} from "../mongodb.js";
3
- import {handleDemoInitialization} from "./data.js";
4
- import { Event} from "../../events.js"
5
- import {Logger} from "../../gameObject.js";
6
- import {hasPermission, middlewareAuthenticator, userInitiator} from "../user.js";
7
- import {isLocalUser} from "../../data.js";
8
- import {getModel} from "./data.operations.js";
9
-
10
- /**
11
- * Compare deux valeurs de manière récursive. Gère les ObjectId, les objets, les tableaux et les primitives.
12
- * @param {*} a - Première valeur.
13
- * @param {*} b - Deuxième valeur.
14
- * @returns {boolean} - True si les valeurs sont sémantiquement égales.
15
- */
16
- function isEqual(a, b) {
17
- if (a === b) return true;
18
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
19
- if (a instanceof ObjectId && b instanceof ObjectId) return a.toString() === b.toString();
20
- if (!a || !b || (typeof a !== 'object' && typeof b !== 'object')) return a === b;
21
- if (a.constructor !== b.constructor) return false;
22
-
23
- if (Array.isArray(a)) {
24
- if (a.length !== b.length) return false;
25
- for (let i = 0; i < a.length; i++) {
26
- if (!isEqual(a[i], b[i])) return false;
27
- }
28
- return true;
29
- }
30
-
31
- if (isPlainObject(a)) {
32
- const keys = Object.keys(a);
33
- if (keys.length !== Object.keys(b).length) return false;
34
- for (const key of keys) {
35
- if (!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key])) {
36
- return false;
37
- }
38
- }
39
- return true;
40
- }
41
-
42
- return false;
43
- }
44
- /**
45
- * Calcule la différence entre deux documents pour les champs historisés.
46
- * @param {object} beforeDoc - Le document avant modification.
47
- * @param {object} afterDoc - Le document après modification.
48
- * @param {string[]} historizedFields - La liste des champs à surveiller.
49
- * @returns {object} - Un objet contenant les changements, ou un objet vide si rien n'a changé.
50
- */
51
- function calculateDiff(beforeDoc, afterDoc, historizedFields) {
52
- const changes = {};
53
- for (const fieldName of historizedFields) {
54
- const beforeValue = beforeDoc[fieldName];
55
- const afterValue = afterDoc[fieldName];
56
-
57
- if (!isEqual(beforeValue, afterValue)) {
58
- safeAssignObject(changes, fieldName, {
59
- from: beforeValue,
60
- to: afterValue
61
- });
62
- }
63
- }
64
- // Retourne null si aucun changement n'a été détecté, ce qui est plus facile à vérifier qu'un objet vide.
65
- return Object.keys(changes).length > 0 ? changes : null;
66
- }
67
- /**
68
- * @route POST /api/data/history/:modelName/:recordId/revert/:version
69
- * @desc Restaure un document à l'état d'une version spécifique.
70
- * @access Private
71
- */
72
- export async function handleRevertToRevisionRequest(req, res) {
73
- const { modelName, recordId, version } = req.params;
74
- const user = req.me;
75
-
76
- try {
77
- // 1. Permissions check (user must be able to edit the data)
78
- if (user && user.username !== 'demo' && isLocalUser(user) && (
79
- !await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_"+modelName], user) ||
80
- await hasPermission(["API_EDIT_DATA_NOT_"+modelName], user))) {
81
- return res.status(403).json({ success: false, error: i18n.t('api.permission.editData') });
82
- }
83
-
84
- // 2. Input validation
85
- const versionInt = parseInt(version, 10);
86
- if (!modelName || !recordId || !isObjectId(recordId) || !version || isNaN(versionInt)) {
87
- return res.status(400).json({ success: false, error: "Invalid model name, record ID, or version." });
88
- }
89
-
90
- // 3. Get the current state of the document (for history diff)
91
- const dataCollection = await getCollectionForUser(user);
92
- const docId = new ObjectId(recordId);
93
- const beforeDoc = await dataCollection.findOne({ _id: docId });
94
-
95
- if (!beforeDoc) {
96
- return res.status(404).json({ success: false, error: "Current document not found." });
97
- }
98
-
99
- // 4. Reconstruct the document to the target version
100
- const documentStateAtVersion = await getDocumentAtVersion(recordId, modelName, versionInt);
101
-
102
- if (!documentStateAtVersion) {
103
- return res.status(404).json({ success: false, error: "Could not reconstruct document at the specified version." });
104
- }
105
-
106
- // 5. Calculate changes between current version and target version
107
- const model = await getModel(modelName, user);
108
- const historizedFields = model?.history?.fields
109
- ? Object.keys(model.history.fields).filter(f => model.history.fields[f] === true)
110
- : model.fields.map(f => f.name);
111
-
112
- const changes = calculateDiff(beforeDoc, documentStateAtVersion, historizedFields);
113
-
114
- // 6. Replace the document with the reverted state
115
- const { _id, ...payloadForReplacement } = documentStateAtVersion;
116
- const replaceResult = await dataCollection.replaceOne({ _id: docId }, payloadForReplacement);
117
-
118
- // On ne crée une entrée d'historique que si des champs historisés ont changé.
119
- // Le document a pu être modifié à cause de champs non-historisés, mais cela
120
- // ne devrait pas créer une nouvelle version dans l'historique.
121
- if (changes) {
122
- // 7. Create a new history entry manually
123
- const historyCollection = getCollection('history');
124
-
125
- // Get last version number
126
- const lastVersionDoc = await historyCollection.findOne(
127
- { documentId: docId },
128
- { projection: { version: 1 }, sort: { version: -1 } }
129
- );
130
- const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1;
131
-
132
- await historyCollection.insertOne({
133
- documentId: docId,
134
- model: modelName,
135
- timestamp: new Date(),
136
- user: { _id: user._id, username: user.username },
137
- version: newVersion,
138
- operation: 'update', // Une restauration est enregistrée comme une 'update'
139
- changes: changes
140
- });
141
-
142
- logger.info(`User ${user.username} reverted document ${modelName}:${recordId} to version ${version}. New history entry created (v${newVersion}).`);
143
- } else if (replaceResult.modifiedCount > 0) {
144
- logger.info(`Document ${modelName}:${recordId} was modified during revert, but no historized fields changed. No new history entry created.`);
145
- }
146
-
147
- res.json({ success: true, message: "Document successfully reverted." });
148
-
149
- } catch (error) {
150
- logger.error(`[handleRevertToRevisionRequest] Error reverting document ${modelName}:${recordId} to version ${version}:`, error);
151
- res.status(500).json({
152
- success: false,
153
- error: 'An internal server error occurred during the revert operation.'
154
- });
155
- }
156
- }
157
-
158
-
159
- /**
160
- * @route GET /api/data/history/:modelName/:recordId
161
- * @desc Récupère l'historique d'un enregistrement spécifique pour le frontend.
162
- * @access Private (géré par middlewareAuthenticator)
163
- */
164
- export async function handleGetHistoryRequest(req, res) {
165
- const { modelName, recordId } = req.params;
166
- const { limit = 10, page = 1 } = req.query;
167
- const user = req.me; // Le middleware d'authentification attache l'utilisateur à req.me
168
-
169
- try {
170
- // 1. Vérification des permissions (similaire à searchData)
171
- if (user && user.username !== 'demo' && isLocalUser(user) && (
172
- !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user) ||
173
- await hasPermission(["API_SEARCH_DATA_NOT_"+modelName], user))) {
174
- return res.status(403).json({ success: false, error: i18n.t('api.permission.searchData') });
175
- }
176
-
177
- // 2. Validation des entrées
178
- if (!modelName || !recordId || !isObjectId(recordId)) {
179
- return res.status(400).json({ success: false, error: "Invalid model name or record ID." });
180
- }
181
-
182
- // 3. Récupération des données depuis la collection 'history'
183
- const historyCollection = getCollection('history');
184
- const filter = {
185
- documentId: new ObjectId(recordId),
186
- model: modelName
187
- };
188
-
189
- const limitInt = parseInt(limit, 10);
190
- const pageInt = parseInt(page, 10);
191
- const skip = (pageInt - 1) * limitInt;
192
-
193
- const [totalCount, historyData] = await Promise.all([
194
- historyCollection.countDocuments(filter),
195
- historyCollection.find(filter)
196
- .sort({ version: -1 })
197
- .skip(skip)
198
- .limit(limitInt)
199
- .toArray()
200
- ]);
201
-
202
- // 4. Transformation des données pour correspondre au format attendu par le composant HistoryDialog.jsx
203
- const opMap = {
204
- create: 'i',
205
- update: 'u',
206
- delete: 'd'
207
- };
208
-
209
- const transformedHistory = historyData.map(entry => {
210
- const { documentId, version, operation, timestamp, user: historyUser, snapshot, changes, ...rest } = entry;
211
-
212
- // Pour l'affichage, on priorise les 'changes' pour une mise à jour,
213
- // et le 'snapshot' pour une création ou suppression.
214
- let dataPayload;
215
- if (operation === 'update') {
216
- dataPayload = changes || {};
217
- } else { // 'create', 'delete'
218
- dataPayload = snapshot || {};
219
- }
220
-
221
- // On exclut les métadonnées du payload pour ne pas les dupliquer
222
- const { _id: originalDocId, _model, _user, _hash, ...payloadFields } = dataPayload;
223
-
224
- return {
225
- ...rest, // Conserve l'_id du document d'historique lui-même
226
- _rid: documentId,
227
- _v: version,
228
- _op: opMap[operation] || operation, // 'create' -> 'i', 'update' -> 'u', etc.
229
- _updatedAt: timestamp,
230
- _user: historyUser?.username,
231
- ...payloadFields // Étale les champs du document (snapshot ou changes)
232
- };
233
- });
234
-
235
- res.json({ success: true, data: transformedHistory, count: totalCount });
236
-
237
- } catch (error) {
238
- logger.error(`[handleGetHistoryRequest] Error fetching history for model ${modelName}, record ${recordId}:`, error);
239
- res.status(500).json({
240
- success: false,
241
- error: 'An internal server error occurred while fetching history.'
242
- });
243
- }
244
- }
245
-
246
- /**
247
- * @route GET /api/data/history/:modelName/:recordId/:version
248
- * @desc Récupère un document à une version spécifique de son historique.
249
- * @access Private (géré par middlewareAuthenticator)
250
- */
251
- export async function handleGetRevisionRequest(req, res) {
252
- const { modelName, recordId, version } = req.params;
253
- const user = req.me;
254
-
255
- try {
256
- // 1. Permissions check (same as getting history)
257
- if (user && user.username !== 'demo' && isLocalUser(user) && (
258
- !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user) ||
259
- await hasPermission(["API_SEARCH_DATA_NOT_"+modelName], user))) {
260
- return res.status(403).json({ success: false, error: i18n.t('api.permission.searchData') });
261
- }
262
-
263
- // 2. Input validation
264
- if (!modelName || !recordId || !isObjectId(recordId) || !version || isNaN(parseInt(version, 10))) {
265
- return res.status(400).json({ success: false, error: "Invalid model name, record ID, or version." });
266
- }
267
-
268
- // 3. Reconstruct the document
269
- const documentState = await getDocumentAtVersion(recordId, modelName, parseInt(version, 10));
270
-
271
- if (!documentState) {
272
- return res.status(404).json({ success: false, error: "Could not reconstruct document at the specified version." });
273
- }
274
-
275
- res.json({ success: true, data: documentState });
276
-
277
- } catch (error) {
278
- logger.error(`[handleGetRevisionRequest] Error fetching revision for model ${modelName}, record ${recordId}, version ${version}:`, error);
279
- res.status(500).json({
280
- success: false,
281
- error: 'An internal server error occurred while fetching the revision.'
282
- });
283
- }
284
- }
285
-
286
-
287
-
288
- let engine, logger;
289
- /**
290
- * Initialise les écouteurs d'événements pour le module d'historique.
291
- * @param {object} defaultEngine - L'instance du moteur.
292
- */
293
- export function onInit(defaultEngine) {
294
-
295
- engine = defaultEngine;
296
- logger = engine.getComponent(Logger);
297
- engine.get('/api/data/history/:modelName/:recordId', [middlewareAuthenticator, userInitiator], handleGetHistoryRequest);
298
- engine.get('/api/data/history/:modelName/:recordId/:version', [middlewareAuthenticator, userInitiator], handleGetRevisionRequest);
299
- engine.post('/api/data/history/:modelName/:recordId/revert/:version', [middlewareAuthenticator, userInitiator], handleRevertToRevisionRequest);
300
-
301
- // --- Écouteur pour la CRÉATION de données (Version 1 - Snapshot) ---
302
- Event.Listen("OnDataAdded", async (engine, { modelName, insertedIds, user }) => {
303
- try {
304
- const model = await getModel(modelName, user);
305
- if (!model?.history?.enabled) return;
306
-
307
- const dataCollection = await getCollectionForUser(user);
308
- const historyCollection = getCollection('history');
309
-
310
- const newDocs = await dataCollection.find({ _id: { $in: insertedIds.map(id => new ObjectId(id)) } }).toArray();
311
-
312
- for (const doc of newDocs) {
313
- await historyCollection.insertOne({
314
- documentId: doc._id,
315
- model: modelName,
316
- timestamp: new Date(),
317
- user: { _id: user._id, username: user.username },
318
- version: 1,
319
- operation: 'create',
320
- snapshot: doc // Pour la création, on stocke un snapshot complet
321
- });
322
- logger.debug(`History v1 (create) created for ${modelName} document ${doc._id}`);
323
- }
324
- } catch (error) {
325
- logger.error("History Module (OnDataAdded) Error:", error);
326
- }
327
- }, "event", "system");
328
-
329
- // --- Écouteur pour la MODIFICATION de données (Versions > 1 - Diff) ---
330
- Event.Listen("OnDataEdited", async (engine, { modelName, user, before, after }) => {
331
- try {
332
- const model = await getModel(modelName, user);
333
- if (!model?.history?.enabled) return;
334
-
335
- // Détermine les champs à historiser. Si non spécifié, tous les champs le sont.
336
- const historizedFields = model.history.fields
337
- ? Object.keys(model.history.fields).filter(f => model.history.fields[f] === true)
338
- : model.fields.map(f => f.name);
339
-
340
- if (historizedFields.length === 0) return; // Pas de champs à suivre
341
-
342
- const historyCollection = getCollection('history');
343
-
344
- for (const afterDoc of after) {
345
- const beforeDoc = before.find(b => b._id.toString() === afterDoc._id.toString());
346
- if (!beforeDoc) {
347
- logger.warn(`History Module: Could not find 'before' state for document ${afterDoc._id}. Skipping history record.`);
348
- continue;
349
- }
350
-
351
- const changes = calculateDiff(beforeDoc, afterDoc, historizedFields);
352
-
353
- // S'il n'y a aucun changement sur les champs surveillés, on ne crée pas d'entrée.
354
- if (!changes) {
355
- continue;
356
- }
357
-
358
- // Récupérer la dernière version pour incrémenter
359
- const lastVersionDoc = await historyCollection.findOne({ documentId: afterDoc._id }, { sort: { version: -1 } });
360
- const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1; // If no history, this is version 1.
361
-
362
- const historyEntry = {
363
- documentId: afterDoc._id,
364
- model: modelName,
365
- timestamp: new Date(),
366
- user: { _id: user._id, username: user.username },
367
- version: newVersion,
368
- operation: 'update',
369
- changes: changes // On stocke uniquement les différences
370
- }
371
-
372
- // If this is the first history record for this document, add a snapshot of the 'before' state.
373
- if (!lastVersionDoc) {
374
- historyEntry.snapshot = beforeDoc;
375
- logger.debug(`History v${newVersion} (update with initial snapshot) created for ${modelName} document ${afterDoc._id}`);
376
- } else {
377
- logger.debug(`History v${newVersion} (update) created for ${modelName} document ${afterDoc._id}`);
378
- }
379
-
380
- await historyCollection.insertOne(historyEntry);
381
- }
382
- } catch (error) {
383
- logger.error("History Module (OnDataEdited) Error:", error);
384
- }
385
- }, "event", "system");
386
-
387
- // --- Écouteur pour la SUPPRESSION de données (Snapshot final) ---
388
- Event.Listen("OnDataDeleted", async (engine, { modelName, user, before }) => {
389
- // 'before' est un tableau des documents complets juste avant leur suppression.
390
- try {
391
- const model = await getModel(modelName, user);
392
- if (!model?.history?.enabled) return;
393
-
394
- const historyCollection = getCollection('history');
395
-
396
- for (const deletedDoc of before) {
397
- // Récupérer la dernière version pour incrémenter
398
- const lastVersionDoc = await historyCollection.findOne({ documentId: deletedDoc._id }, { sort: { version: -1 } });
399
- // Si aucune version n'existe, c'est peut-être un cas l'historique a été activé après la création.
400
- // On commence à 1, sinon on incrémente.
401
- const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1;
402
-
403
- await historyCollection.insertOne({
404
- documentId: deletedDoc._id,
405
- model: modelName,
406
- timestamp: new Date(),
407
- user: { _id: user._id, username: user.username },
408
- version: newVersion,
409
- operation: 'delete',
410
- // On stocke un snapshot final du document supprimé pour audit ou restauration.
411
- snapshot: deletedDoc
412
- });
413
- logger.debug(`History v${newVersion} (delete) created for ${modelName} document ${deletedDoc._id}`);
414
- }
415
- } catch (error) {
416
- logger.error("History Module (OnDataDeleted) Error:", error);
417
- }
418
- }, "event", "system");
419
-
420
- logger.info("History module initialized and listening for data events.");
421
- }
422
-
423
- /**
424
- * Reconstructs a document to a specific version from its history by finding the last
425
- * available snapshot and applying subsequent changes.
426
- * @param {ObjectId|string} documentId - The ID of the document.
427
- * @param {string} modelName - The model name.
428
- * @param {number} targetVersion - The version to reconstruct to.
429
- * @returns {Promise<object|null>} - The reconstructed document object, or null if not found.
430
- */
431
- export async function getDocumentAtVersion(documentId, modelName, targetVersion) {
432
- const historyCollection = getCollection('history');
433
- const docId = typeof documentId === 'string' ? new ObjectId(documentId) : documentId;
434
- const version = parseInt(targetVersion, 10);
435
-
436
- // 1. Find the most recent snapshot at or before the target version.
437
- const lastSnapshotEntry = await historyCollection.findOne({
438
- documentId: docId,
439
- model: modelName,
440
- version: { $lte: version },
441
- snapshot: { $exists: true }
442
- }, { sort: { version: -1 } });
443
-
444
- if (!lastSnapshotEntry) {
445
- logger.warn(`No snapshot found for ${modelName}:${documentId} up to version ${version}. Cannot reconstruct.`);
446
- return null;
447
- }
448
-
449
- // 2. Start with this snapshot.
450
- let reconstructedDoc = lastSnapshotEntry.snapshot;
451
-
452
- // If the snapshot entry itself is an update, apply its changes to the base snapshot.
453
- // This handles the case where the first history entry is an update with a snapshot.
454
- if (lastSnapshotEntry.operation === 'update' && lastSnapshotEntry.changes) {
455
- for (const fieldName in lastSnapshotEntry.changes) {
456
- if (Object.prototype.hasOwnProperty.call(lastSnapshotEntry.changes, fieldName)) {
457
- reconstructedDoc[fieldName] = lastSnapshotEntry.changes[fieldName].to;
458
- }
459
- }
460
- }
461
-
462
- if (lastSnapshotEntry.version === version) {
463
- return reconstructedDoc;
464
- }
465
-
466
- // 3. Find all 'update' operations between our snapshot's version and the target version.
467
- const updatesToApply = await historyCollection.find({
468
- documentId: docId,
469
- model: modelName,
470
- version: { $gt: lastSnapshotEntry.version, $lte: version },
471
- operation: 'update'
472
- }).sort({ version: 1 }).toArray();
473
-
474
- // 4. Apply the changes sequentially.
475
- for (const update of updatesToApply) {
476
- if (update.changes) {
477
- for (const fieldName in update.changes) {
478
- if (Object.prototype.hasOwnProperty.call(update.changes, fieldName)) {
479
- reconstructedDoc[fieldName] = update.changes[fieldName].to;
480
- }
481
- }
482
- }
483
- }
484
-
485
- return reconstructedDoc;
486
- }
487
-
488
- /**
489
- * Purge (supprime définitivement) des documents et tout leur historique associé.
490
- * C'est une opération destructive à utiliser avec précaution.
491
- * @param {object} user - L'utilisateur effectuant l'opération (pour les permissions).
492
- * @param {string} modelName - Le nom du modèle concerné.
493
- * @param {object} filter - Le filtre MongoDB pour trouver les documents à purger.
494
- * @returns {Promise<{success: boolean, purgedCount: number, historyPurgedCount: number, error?: string}>}
495
- */
496
- export async function purgeData(user, modelName = null, filter=null) {
497
- const logger = new Logger("purgeData");
498
- try {
499
- const dataCollection = await getCollectionForUser(user);
500
- const historyCollection = getCollection('history');
501
-
502
- let m = modelName || { _model: modelName };
503
- const f= filter || { _user: user.username };
504
- // 1. Trouver les documents à purger pour récupérer leurs IDs
505
- const docsToPurge = await dataCollection.find({ ...m, ...f }).project({ _id: 1 }).toArray();
506
- if (docsToPurge.length === 0) {
507
- return { success: true, purgedCount: 0, historyPurgedCount: 0 };
508
- }
509
- const docIdsToPurge = docsToPurge.map(d => d._id);
510
-
511
- // 2. Purger l'historique associé à ces documents
512
- const historyResult = await historyCollection.deleteMany({ documentId: { $in: docIdsToPurge } });
513
-
514
- // 3. Purger les documents eux-mêmes
515
- const dataResult = await dataCollection.deleteMany({ _id: { $in: docIdsToPurge } });
516
-
517
- logger.info(`Purged ${dataResult.deletedCount} documents and ${historyResult.deletedCount} history entries for model '${modelName}'.`);
518
-
519
- // On pourrait aussi émettre un événement "OnDataPurged" ici si nécessaire
520
- await Event.Trigger("OnDataPurged", "event","system",{ user, modelName, purgedIds: docIdsToPurge });
521
-
522
- return {
523
- success: true,
524
- purgedCount: dataResult.deletedCount,
525
- historyPurgedCount: historyResult.deletedCount
526
- };
527
-
528
- } catch (error) {
529
- logger.error(`Error during data purge for model '${modelName}':`, error);
530
- return { success: false, purgedCount: 0, historyPurgedCount: 0, error: error.message };
531
- }
1
+ import {isPlainObject, safeAssignObject} from "../../core.js";
2
+ import {getCollection, getCollectionForUser, isObjectId, ObjectId} from "../mongodb.js";
3
+ import {handleDemoInitialization} from "./data.js";
4
+ import { Event} from "../../events.js"
5
+ import {Logger} from "../../gameObject.js";
6
+ import {hasPermission, middlewareAuthenticator, userInitiator} from "../user.js";
7
+ import {isLocalUser} from "../../data.js";
8
+ import {getModel} from "./data.operations.js";
9
+
10
+ /**
11
+ * Compare deux valeurs de manière récursive. Gère les ObjectId, les objets, les tableaux et les primitives.
12
+ * @param {*} a - Première valeur.
13
+ * @param {*} b - Deuxième valeur.
14
+ * @returns {boolean} - True si les valeurs sont sémantiquement égales.
15
+ */
16
+ function isEqual(a, b) {
17
+ if (a === b) return true;
18
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
19
+ if (a instanceof ObjectId && b instanceof ObjectId) return a.toString() === b.toString();
20
+ if (!a || !b || (typeof a !== 'object' && typeof b !== 'object')) return a === b;
21
+ if (a.constructor !== b.constructor) return false;
22
+
23
+ if (Array.isArray(a)) {
24
+ if (a.length !== b.length) return false;
25
+ for (let i = 0; i < a.length; i++) {
26
+ if (!isEqual(a[i], b[i])) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ if (isPlainObject(a)) {
32
+ const keys = Object.keys(a);
33
+ if (keys.length !== Object.keys(b).length) return false;
34
+ for (const key of keys) {
35
+ if (!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key])) {
36
+ return false;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+
42
+ return false;
43
+ }
44
+ /**
45
+ * Calcule la différence entre deux documents pour les champs historisés.
46
+ * @param {object} beforeDoc - Le document avant modification.
47
+ * @param {object} afterDoc - Le document après modification.
48
+ * @param {string[]} historizedFields - La liste des champs à surveiller.
49
+ * @returns {object} - Un objet contenant les changements, ou un objet vide si rien n'a changé.
50
+ */
51
+ function calculateDiff(beforeDoc, afterDoc, historizedFields) {
52
+ const changes = {};
53
+ for (const fieldName of historizedFields) {
54
+ const beforeValue = beforeDoc[fieldName];
55
+ const afterValue = afterDoc[fieldName];
56
+
57
+ if (!isEqual(beforeValue, afterValue)) {
58
+ safeAssignObject(changes, fieldName, {
59
+ from: beforeValue,
60
+ to: afterValue
61
+ });
62
+ }
63
+ }
64
+ // Retourne null si aucun changement n'a été détecté, ce qui est plus facile à vérifier qu'un objet vide.
65
+ return Object.keys(changes).length > 0 ? changes : null;
66
+ }
67
+ /**
68
+ * @route POST /api/data/history/:modelName/:recordId/revert/:version
69
+ * @desc Restaure un document à l'état d'une version spécifique.
70
+ * @access Private
71
+ */
72
+ export async function handleRevertToRevisionRequest(req, res) {
73
+ const { modelName, recordId, version } = req.params;
74
+ const user = req.me;
75
+
76
+ try {
77
+ // 1. Permissions check (user must be able to edit the data)
78
+ if (user && user.username !== 'demo' && isLocalUser(user) && (
79
+ !await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_"+modelName], user) ||
80
+ await hasPermission(["API_EDIT_DATA_NOT_"+modelName], user))) {
81
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.editData') });
82
+ }
83
+
84
+ // 2. Input validation
85
+ const versionInt = parseInt(version, 10);
86
+ if (!modelName || !recordId || !isObjectId(recordId) || !version || isNaN(versionInt)) {
87
+ return res.status(400).json({ success: false, error: "Invalid model name, record ID, or version." });
88
+ }
89
+
90
+ // 3. Get the current state of the document (for history diff)
91
+ const dataCollection = await getCollectionForUser(user);
92
+ const docId = new ObjectId(recordId);
93
+ const beforeDoc = await dataCollection.findOne({ _id: docId });
94
+
95
+ if (!beforeDoc) {
96
+ return res.status(404).json({ success: false, error: "Current document not found." });
97
+ }
98
+
99
+ // 4. Reconstruct the document to the target version
100
+ const documentStateAtVersion = await getDocumentAtVersion(recordId, modelName, versionInt);
101
+
102
+ if (!documentStateAtVersion) {
103
+ return res.status(404).json({ success: false, error: "Could not reconstruct document at the specified version." });
104
+ }
105
+
106
+ // 5. Calculate changes between current version and target version
107
+ const model = await getModel(modelName, user);
108
+ const historizedFields = model?.history?.fields
109
+ ? Object.keys(model.history.fields).filter(f => model.history.fields[f] === true)
110
+ : model.fields.map(f => f.name);
111
+
112
+ const changes = calculateDiff(beforeDoc, documentStateAtVersion, historizedFields);
113
+
114
+ // 6. Replace the document with the reverted state
115
+ const { _id, ...payloadForReplacement } = documentStateAtVersion;
116
+ const replaceResult = await dataCollection.replaceOne({ _id: docId }, payloadForReplacement);
117
+
118
+ // On ne crée une entrée d'historique que si des champs historisés ont changé.
119
+ // Le document a pu être modifié à cause de champs non-historisés, mais cela
120
+ // ne devrait pas créer une nouvelle version dans l'historique.
121
+ if (changes) {
122
+ // 7. Create a new history entry manually
123
+ const historyCollection = getCollection('history');
124
+
125
+ // Get last version number
126
+ const lastVersionDoc = await historyCollection.findOne(
127
+ { documentId: docId },
128
+ { projection: { version: 1 }, sort: { version: -1 } }
129
+ );
130
+ const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1;
131
+
132
+ await historyCollection.insertOne({
133
+ documentId: docId,
134
+ model: modelName,
135
+ timestamp: new Date(),
136
+ user: { _id: user._id, username: user.username },
137
+ version: newVersion,
138
+ operation: 'update', // Une restauration est enregistrée comme une 'update'
139
+ changes: changes
140
+ });
141
+
142
+ logger.info(`User ${user.username} reverted document ${modelName}:${recordId} to version ${version}. New history entry created (v${newVersion}).`);
143
+ } else if (replaceResult.modifiedCount > 0) {
144
+ logger.info(`Document ${modelName}:${recordId} was modified during revert, but no historized fields changed. No new history entry created.`);
145
+ }
146
+
147
+ res.json({ success: true, message: "Document successfully reverted." });
148
+
149
+ } catch (error) {
150
+ logger.error(`[handleRevertToRevisionRequest] Error reverting document ${modelName}:${recordId} to version ${version}:`, error);
151
+ res.status(500).json({
152
+ success: false,
153
+ error: 'An internal server error occurred during the revert operation.'
154
+ });
155
+ }
156
+ }
157
+
158
+
159
+ /**
160
+ * @route GET /api/data/history/:modelName/:recordId
161
+ * @desc Récupère l'historique d'un enregistrement spécifique pour le frontend.
162
+ * @access Private (géré par middlewareAuthenticator)
163
+ */
164
+ export async function handleGetHistoryRequest(req, res) {
165
+ const { modelName, recordId } = req.params;
166
+ const { limit = 10, page = 1, startDate, endDate } = req.query;
167
+ const user = req.me; // Le middleware d'authentification attache l'utilisateur à req.me
168
+
169
+ try {
170
+ // 1. Vérification des permissions (similaire à searchData)
171
+ if (user && user.username !== 'demo' && isLocalUser(user) && (
172
+ !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user) ||
173
+ await hasPermission(["API_SEARCH_DATA_NOT_"+modelName], user))) {
174
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.searchData') });
175
+ }
176
+
177
+ // 2. Validation des entrées
178
+ if (!modelName || !recordId || !isObjectId(recordId)) {
179
+ return res.status(400).json({ success: false, error: "Invalid model name or record ID." });
180
+ }
181
+
182
+ // 3. Récupération des données depuis la collection 'history'
183
+ const historyCollection = getCollection('history');
184
+ const filterConditions = [
185
+ { "$eq": ["$documentId", new ObjectId(recordId)]},
186
+ { "$eq": ["$model", modelName]}
187
+ ];
188
+
189
+ // Ajout du filtre par plage de dates
190
+ if (startDate) {
191
+ try {
192
+ filterConditions.push({ $gte: ["$timestamp", new Date(startDate)] });
193
+ } catch (e) { /* ignore invalid date */ }
194
+ }
195
+ if (endDate) {
196
+ try {
197
+ const endOfDay = new Date(endDate);
198
+ endOfDay.setUTCHours(23, 59, 59, 999); // Inclusif pour toute la journée de fin
199
+ filterConditions.push({ $lte: ["$timestamp", endOfDay] });
200
+ } catch (e) { /* ignore invalid date */ }
201
+ }
202
+
203
+ const filter = { "$and": filterConditions };
204
+
205
+ const limitInt = parseInt(limit, 10);
206
+ const pageInt = parseInt(page, 10);
207
+ const skip = (pageInt - 1) * limitInt;
208
+
209
+ // Utiliser une agrégation pour le comptage afin de s'assurer que le filtre $expr est correctement appliqué.
210
+ const countPipeline = [
211
+ { $match: { $expr: filter } },
212
+ { $count: "count" }
213
+ ];
214
+
215
+ const [countResult, historyData] = await Promise.all([
216
+ historyCollection.aggregate(countPipeline).toArray(),
217
+ historyCollection.aggregate([{$match: {$expr: filter}}])
218
+ .sort({ version: -1 })
219
+ .skip(skip)
220
+ .limit(limitInt)
221
+ .toArray()
222
+ ]);
223
+
224
+ const totalCount = countResult.length > 0 ? countResult[0].count : 0;
225
+
226
+ // 4. Transformation des données pour correspondre au format attendu par le composant HistoryDialog.jsx
227
+ const opMap = {
228
+ create: 'i',
229
+ update: 'u',
230
+ delete: 'd'
231
+ };
232
+
233
+ const transformedHistory = historyData.map(entry => {
234
+ const { documentId, version, operation, timestamp, user: historyUser, snapshot, changes, ...rest } = entry;
235
+
236
+ // Pour l'affichage, on priorise les 'changes' pour une mise à jour,
237
+ // et le 'snapshot' pour une création ou suppression.
238
+ let dataPayload;
239
+ if (operation === 'update') {
240
+ dataPayload = changes || {};
241
+ } else { // 'create', 'delete'
242
+ dataPayload = snapshot || {};
243
+ }
244
+
245
+ // On exclut les métadonnées du payload pour ne pas les dupliquer
246
+ const { _id: originalDocId, _model, _user, _hash, ...payloadFields } = dataPayload;
247
+
248
+ return {
249
+ ...rest, // Conserve l'_id du document d'historique lui-même
250
+ _rid: documentId,
251
+ _v: version,
252
+ _op: opMap[operation] || operation, // 'create' -> 'i', 'update' -> 'u', etc.
253
+ _updatedAt: timestamp,
254
+ _user: historyUser?.username,
255
+ ...payloadFields // Étale les champs du document (snapshot ou changes)
256
+ };
257
+ });
258
+
259
+ res.json({ success: true, data: transformedHistory, count: totalCount });
260
+
261
+ } catch (error) {
262
+ logger.error(`[handleGetHistoryRequest] Error fetching history for model ${modelName}, record ${recordId}:`, error);
263
+ res.status(500).json({
264
+ success: false,
265
+ error: 'An internal server error occurred while fetching history.'
266
+ });
267
+ }
268
+ }
269
+
270
+ /**
271
+ * @route GET /api/data/history/:modelName/:recordId/:version
272
+ * @desc Récupère un document à une version spécifique de son historique.
273
+ * @access Private (géré par middlewareAuthenticator)
274
+ */
275
+ export async function handleGetRevisionRequest(req, res) {
276
+ const { modelName, recordId, version } = req.params;
277
+ const user = req.me;
278
+
279
+ try {
280
+ // 1. Permissions check (same as getting history)
281
+ if (user && user.username !== 'demo' && isLocalUser(user) && (
282
+ !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user) ||
283
+ await hasPermission(["API_SEARCH_DATA_NOT_"+modelName], user))) {
284
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.searchData') });
285
+ }
286
+
287
+ // 2. Input validation
288
+ if (!modelName || !recordId || !isObjectId(recordId) || !version || isNaN(parseInt(version, 10))) {
289
+ return res.status(400).json({ success: false, error: "Invalid model name, record ID, or version." });
290
+ }
291
+
292
+ // 3. Reconstruct the document
293
+ const documentState = await getDocumentAtVersion(recordId, modelName, parseInt(version, 10));
294
+
295
+ if (!documentState) {
296
+ return res.status(404).json({ success: false, error: "Could not reconstruct document at the specified version." });
297
+ }
298
+
299
+ res.json({ success: true, data: documentState });
300
+
301
+ } catch (error) {
302
+ logger.error(`[handleGetRevisionRequest] Error fetching revision for model ${modelName}, record ${recordId}, version ${version}:`, error);
303
+ res.status(500).json({
304
+ success: false,
305
+ error: 'An internal server error occurred while fetching the revision.'
306
+ });
307
+ }
308
+ }
309
+
310
+
311
+
312
+ let engine, logger;
313
+ /**
314
+ * Initialise les écouteurs d'événements pour le module d'historique.
315
+ * @param {object} defaultEngine - L'instance du moteur.
316
+ */
317
+ export function onInit(defaultEngine) {
318
+
319
+ engine = defaultEngine;
320
+ logger = engine.getComponent(Logger);
321
+ engine.get('/api/data/history/:modelName/:recordId', [middlewareAuthenticator, userInitiator], handleGetHistoryRequest);
322
+ engine.get('/api/data/history/:modelName/:recordId/:version', [middlewareAuthenticator, userInitiator], handleGetRevisionRequest);
323
+ engine.post('/api/data/history/:modelName/:recordId/revert/:version', [middlewareAuthenticator, userInitiator], handleRevertToRevisionRequest);
324
+
325
+ // --- Écouteur pour la CRÉATION de données (Version 1 - Snapshot) ---
326
+ Event.Listen("OnDataAdded", async (engine, { modelName, insertedIds, user }) => {
327
+ try {
328
+ const model = await getModel(modelName, user);
329
+ if (!model?.history?.enabled) return;
330
+
331
+ const dataCollection = await getCollectionForUser(user);
332
+ const historyCollection = getCollection('history');
333
+
334
+ const newDocs = await dataCollection.find({ _id: { $in: insertedIds.map(id => new ObjectId(id)) } }).toArray();
335
+
336
+ for (const doc of newDocs) {
337
+ await historyCollection.insertOne({
338
+ documentId: doc._id,
339
+ model: modelName,
340
+ timestamp: new Date(),
341
+ user: { _id: user._id, username: user.username },
342
+ version: 1,
343
+ operation: 'create',
344
+ snapshot: doc // Pour la création, on stocke un snapshot complet
345
+ });
346
+ logger.debug(`History v1 (create) created for ${modelName} document ${doc._id}`);
347
+ }
348
+ } catch (error) {
349
+ logger.error("History Module (OnDataAdded) Error:", error);
350
+ }
351
+ }, "event", "system");
352
+
353
+ // --- Écouteur pour la MODIFICATION de données (Versions > 1 - Diff) ---
354
+ Event.Listen("OnDataEdited", async (engine, { modelName, user, before, after }) => {
355
+ try {
356
+ const model = await getModel(modelName, user);
357
+ if (!model?.history?.enabled) return;
358
+
359
+ // Détermine les champs à historiser. Si non spécifié, tous les champs le sont.
360
+ const historizedFields = model.history.fields
361
+ ? Object.keys(model.history.fields).filter(f => model.history.fields[f] === true)
362
+ : model.fields.map(f => f.name);
363
+
364
+ if (historizedFields.length === 0) return; // Pas de champs à suivre
365
+
366
+ const historyCollection = getCollection('history');
367
+
368
+ for (const afterDoc of after) {
369
+ const beforeDoc = before.find(b => b._id.toString() === afterDoc._id.toString());
370
+ if (!beforeDoc) {
371
+ logger.warn(`History Module: Could not find 'before' state for document ${afterDoc._id}. Skipping history record.`);
372
+ continue;
373
+ }
374
+
375
+ const changes = calculateDiff(beforeDoc, afterDoc, historizedFields);
376
+
377
+ // S'il n'y a aucun changement sur les champs surveillés, on ne crée pas d'entrée.
378
+ if (!changes) {
379
+ continue;
380
+ }
381
+
382
+ // Récupérer la dernière version pour incrémenter
383
+ const lastVersionDoc = await historyCollection.findOne({ documentId: afterDoc._id }, { sort: { version: -1 } });
384
+ const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1; // If no history, this is version 1.
385
+
386
+ const historyEntry = {
387
+ documentId: afterDoc._id,
388
+ model: modelName,
389
+ timestamp: new Date(),
390
+ user: { _id: user._id, username: user.username },
391
+ version: newVersion,
392
+ operation: 'update',
393
+ changes: changes // On stocke uniquement les différences
394
+ }
395
+
396
+ // If this is the first history record for this document, add a snapshot of the 'before' state.
397
+ if (!lastVersionDoc) {
398
+ historyEntry.snapshot = beforeDoc;
399
+ logger.debug(`History v${newVersion} (update with initial snapshot) created for ${modelName} document ${afterDoc._id}`);
400
+ } else {
401
+ logger.debug(`History v${newVersion} (update) created for ${modelName} document ${afterDoc._id}`);
402
+ }
403
+
404
+ await historyCollection.insertOne(historyEntry);
405
+ }
406
+ } catch (error) {
407
+ logger.error("History Module (OnDataEdited) Error:", error);
408
+ }
409
+ }, "event", "system");
410
+
411
+ // --- Écouteur pour la SUPPRESSION de données (Snapshot final) ---
412
+ Event.Listen("OnDataDeleted", async (engine, { modelName, user, before }) => {
413
+ // 'before' est un tableau des documents complets juste avant leur suppression.
414
+ try {
415
+ const model = await getModel(modelName, user);
416
+ if (!model?.history?.enabled) return;
417
+
418
+ const historyCollection = getCollection('history');
419
+
420
+ for (const deletedDoc of before) {
421
+ // Récupérer la dernière version pour incrémenter
422
+ const lastVersionDoc = await historyCollection.findOne({ documentId: deletedDoc._id }, { sort: { version: -1 } });
423
+ // Si aucune version n'existe, c'est peut-être un cas où l'historique a été activé après la création.
424
+ // On commence à 1, sinon on incrémente.
425
+ const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1;
426
+
427
+ await historyCollection.insertOne({
428
+ documentId: deletedDoc._id,
429
+ model: modelName,
430
+ timestamp: new Date(),
431
+ user: { _id: user._id, username: user.username },
432
+ version: newVersion,
433
+ operation: 'delete',
434
+ // On stocke un snapshot final du document supprimé pour audit ou restauration.
435
+ snapshot: deletedDoc
436
+ });
437
+ logger.debug(`History v${newVersion} (delete) created for ${modelName} document ${deletedDoc._id}`);
438
+ }
439
+ } catch (error) {
440
+ logger.error("History Module (OnDataDeleted) Error:", error);
441
+ }
442
+ }, "event", "system");
443
+
444
+ logger.info("History module initialized and listening for data events.");
445
+ }
446
+
447
+ /**
448
+ * Reconstructs a document to a specific version from its history by finding the last
449
+ * available snapshot and applying subsequent changes.
450
+ * @param {ObjectId|string} documentId - The ID of the document.
451
+ * @param {string} modelName - The model name.
452
+ * @param {number} targetVersion - The version to reconstruct to.
453
+ * @returns {Promise<object|null>} - The reconstructed document object, or null if not found.
454
+ */
455
+ export async function getDocumentAtVersion(documentId, modelName, targetVersion) {
456
+ const historyCollection = getCollection('history');
457
+ const docId = typeof documentId === 'string' ? new ObjectId(documentId) : documentId;
458
+ const version = parseInt(targetVersion, 10);
459
+
460
+ // 1. Find the most recent snapshot at or before the target version.
461
+ const lastSnapshotEntry = await historyCollection.findOne({
462
+ documentId: docId,
463
+ model: modelName,
464
+ version: { $lte: version },
465
+ snapshot: { $exists: true }
466
+ }, { sort: { version: -1 } });
467
+
468
+ if (!lastSnapshotEntry) {
469
+ logger.warn(`No snapshot found for ${modelName}:${documentId} up to version ${version}. Cannot reconstruct.`);
470
+ return null;
471
+ }
472
+
473
+ // 2. Start with this snapshot.
474
+ let reconstructedDoc = lastSnapshotEntry.snapshot;
475
+
476
+ // If the snapshot entry itself is an update, apply its changes to the base snapshot.
477
+ // This handles the case where the first history entry is an update with a snapshot.
478
+ if (lastSnapshotEntry.operation === 'update' && lastSnapshotEntry.changes) {
479
+ for (const fieldName in lastSnapshotEntry.changes) {
480
+ if (Object.prototype.hasOwnProperty.call(lastSnapshotEntry.changes, fieldName)) {
481
+ reconstructedDoc[fieldName] = lastSnapshotEntry.changes[fieldName].to;
482
+ }
483
+ }
484
+ }
485
+
486
+ if (lastSnapshotEntry.version === version) {
487
+ return reconstructedDoc;
488
+ }
489
+
490
+ // 3. Find all 'update' operations between our snapshot's version and the target version.
491
+ const updatesToApply = await historyCollection.find({
492
+ documentId: docId,
493
+ model: modelName,
494
+ version: { $gt: lastSnapshotEntry.version, $lte: version },
495
+ operation: 'update'
496
+ }).sort({ version: 1 }).toArray();
497
+
498
+ // 4. Apply the changes sequentially.
499
+ for (const update of updatesToApply) {
500
+ if (update.changes) {
501
+ for (const fieldName in update.changes) {
502
+ if (Object.prototype.hasOwnProperty.call(update.changes, fieldName)) {
503
+ reconstructedDoc[fieldName] = update.changes[fieldName].to;
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ return reconstructedDoc;
510
+ }
511
+
512
+ /**
513
+ * Purge (supprime définitivement) des documents et tout leur historique associé.
514
+ * C'est une opération destructive à utiliser avec précaution.
515
+ * @param {object} user - L'utilisateur effectuant l'opération (pour les permissions).
516
+ * @param {string} modelName - Le nom du modèle concerné.
517
+ * @param {object} filter - Le filtre MongoDB pour trouver les documents à purger.
518
+ * @returns {Promise<{success: boolean, purgedCount: number, historyPurgedCount: number, error?: string}>}
519
+ */
520
+ export async function purgeData(user, modelName = null, filter=null) {
521
+ const logger = new Logger("purgeData");
522
+ try {
523
+ const dataCollection = await getCollectionForUser(user);
524
+ const historyCollection = getCollection('history');
525
+
526
+ let m = modelName || { _model: modelName };
527
+ const f= filter || { _user: user.username };
528
+ // 1. Trouver les documents à purger pour récupérer leurs IDs
529
+ const docsToPurge = await dataCollection.find({ ...m, ...f }).project({ _id: 1 }).toArray();
530
+ if (docsToPurge.length === 0) {
531
+ return { success: true, purgedCount: 0, historyPurgedCount: 0 };
532
+ }
533
+ const docIdsToPurge = docsToPurge.map(d => d._id);
534
+
535
+ // 2. Purger l'historique associé à ces documents
536
+ const historyResult = await historyCollection.deleteMany({ documentId: { $in: docIdsToPurge } });
537
+
538
+ // 3. Purger les documents eux-mêmes
539
+ const dataResult = await dataCollection.deleteMany({ _id: { $in: docIdsToPurge } });
540
+
541
+ logger.info(`Purged ${dataResult.deletedCount} documents and ${historyResult.deletedCount} history entries for model '${modelName}'.`);
542
+
543
+ // On pourrait aussi émettre un événement "OnDataPurged" ici si nécessaire
544
+ await Event.Trigger("OnDataPurged", "event","system",{ user, modelName, purgedIds: docIdsToPurge });
545
+
546
+ return {
547
+ success: true,
548
+ purgedCount: dataResult.deletedCount,
549
+ historyPurgedCount: historyResult.deletedCount
550
+ };
551
+
552
+ } catch (error) {
553
+ logger.error(`Error during data purge for model '${modelName}':`, error);
554
+ return { success: false, purgedCount: 0, historyPurgedCount: 0, error: error.message };
555
+ }
532
556
  }