data-primals-engine 1.3.0 → 1.3.1
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/CHANGELOG.md +440 -0
- package/README.md +782 -748
- package/client/src/App.scss +4 -0
- package/client/src/DataLayout.jsx +2 -2
- package/client/src/DataTable.jsx +19 -2
- package/client/src/Field.jsx +3 -2
- package/client/src/HistoryDialog.jsx +96 -0
- package/client/src/HistoryDialog.scss +73 -0
- package/client/src/ModelCreator.jsx +36 -15
- package/client/src/ModelCreatorField.jsx +5 -6
- package/client/src/hooks/useTutorials.jsx +1 -1
- package/client/src/translations.js +155 -1
- package/package.json +2 -2
- package/src/HistoryDialog.jsx +86 -0
- package/src/HistoryDialog.scss +77 -0
- package/src/email.js +1 -1
- package/src/engine.js +6 -6
- package/src/events.js +15 -5
- package/src/gameObject.js +0 -29
- package/src/modules/data/data.history.js +304 -0
- package/src/modules/data/data.js +4620 -4601
- package/src/modules/data/data.routes.js +4 -4
- package/src/modules/mongodb.js +17 -1
- package/src/modules/user.js +9 -3
- package/src/packs.js +2 -1
- package/src/services/stripe.js +67 -13
- package/src/workers/crypto-worker.js +3 -3
- package/test/data.backup.integration.test.js +5 -0
- package/test/data.history.integration.test.js +193 -0
- package/test/data.integration.test.js +79 -39
- package/test/events.test.js +27 -27
- package/test/globalTeardown.js +1 -0
- package/test/model.integration.test.js +5 -0
- package/test/workflow.actions.integration.test.js +2 -0
- package/test/workflow.integration.test.js +2 -0
- package/test/workflow.robustness.test.js +2 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {isPlainObject} from "../../core.js";
|
|
2
|
+
import {getCollection, getCollectionForUser, isObjectId, ObjectId} from "../mongodb.js";
|
|
3
|
+
import {getModel, 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
|
+
|
|
9
|
+
let logger;
|
|
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
|
+
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
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @route GET /api/data/history/:modelName/:recordId
|
|
71
|
+
* @desc Récupère l'historique d'un enregistrement spécifique pour le frontend.
|
|
72
|
+
* @access Private (géré par middlewareAuthenticator)
|
|
73
|
+
*/
|
|
74
|
+
export async function handleGetHistoryRequest(req, res) {
|
|
75
|
+
const { modelName, recordId } = req.params;
|
|
76
|
+
const user = req.me; // Le middleware d'authentification attache l'utilisateur à req.me
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// 1. Vérification des permissions (similaire à searchData)
|
|
80
|
+
if (user && user.username !== 'demo' && isLocalUser(user) && (
|
|
81
|
+
!await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user) ||
|
|
82
|
+
await hasPermission(["API_SEARCH_DATA_NOT_"+modelName], user))) {
|
|
83
|
+
return res.status(403).json({ success: false, error: i18n.t('api.permission.searchData') });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Validation des entrées
|
|
87
|
+
if (!modelName || !recordId || !isObjectId(recordId)) {
|
|
88
|
+
return res.status(400).json({ success: false, error: "Invalid model name or record ID." });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. Récupération des données depuis la collection 'history'
|
|
92
|
+
const historyCollection = getCollection('history');
|
|
93
|
+
const historyData = await historyCollection.find({
|
|
94
|
+
documentId: new ObjectId(recordId),
|
|
95
|
+
model: modelName
|
|
96
|
+
}).sort({ version: -1 }).toArray(); // Trier du plus récent au plus ancien
|
|
97
|
+
|
|
98
|
+
// 4. Transformation des données pour correspondre au format attendu par le composant HistoryDialog.jsx
|
|
99
|
+
const opMap = {
|
|
100
|
+
create: 'i',
|
|
101
|
+
update: 'u',
|
|
102
|
+
delete: 'd'
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const transformedHistory = historyData.map(entry => {
|
|
106
|
+
const { documentId, version, operation, timestamp, user: historyUser, snapshot, changes, ...rest } = entry;
|
|
107
|
+
|
|
108
|
+
// Le contenu est soit un snapshot complet, soit un objet de changements
|
|
109
|
+
const dataPayload = snapshot || changes || {};
|
|
110
|
+
|
|
111
|
+
// On exclut les métadonnées du payload pour ne pas les dupliquer
|
|
112
|
+
const { _id: originalDocId, _model, _user, _hash, ...payloadFields } = dataPayload;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...rest, // Conserve l'_id du document d'historique lui-même
|
|
116
|
+
_rid: documentId,
|
|
117
|
+
_v: version,
|
|
118
|
+
_op: opMap[operation] || operation, // 'create' -> 'i', 'update' -> 'u', etc.
|
|
119
|
+
_updatedAt: timestamp,
|
|
120
|
+
_user: historyUser?.username,
|
|
121
|
+
...payloadFields // Étale les champs du document (snapshot ou changes)
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
res.json({ success: true, data: transformedHistory });
|
|
126
|
+
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logger.error(`[handleGetHistoryRequest] Error fetching history for model ${modelName}, record ${recordId}:`, error);
|
|
129
|
+
res.status(500).json({
|
|
130
|
+
success: false,
|
|
131
|
+
error: 'An internal server error occurred while fetching history.'
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Initialise les écouteurs d'événements pour le module d'historique.
|
|
139
|
+
* @param {object} engine - L'instance du moteur.
|
|
140
|
+
*/
|
|
141
|
+
export function onInit(engine) {
|
|
142
|
+
logger = engine.getComponent(Logger);
|
|
143
|
+
|
|
144
|
+
engine.get('/api/data/history/:modelName/:recordId', [middlewareAuthenticator, userInitiator], handleGetHistoryRequest);
|
|
145
|
+
|
|
146
|
+
// --- Écouteur pour la CRÉATION de données (Version 1 - Snapshot) ---
|
|
147
|
+
Event.Listen("OnDataAdded", async (engine, { modelName, insertedIds, user }) => {
|
|
148
|
+
try {
|
|
149
|
+
const model = await getModel(modelName, user);
|
|
150
|
+
if (!model?.history?.enabled) return;
|
|
151
|
+
|
|
152
|
+
const dataCollection = await getCollectionForUser(user);
|
|
153
|
+
const historyCollection = getCollection('history');
|
|
154
|
+
|
|
155
|
+
const newDocs = await dataCollection.find({ _id: { $in: insertedIds.map(id => new ObjectId(id)) } }).toArray();
|
|
156
|
+
|
|
157
|
+
for (const doc of newDocs) {
|
|
158
|
+
await historyCollection.insertOne({
|
|
159
|
+
documentId: doc._id,
|
|
160
|
+
model: modelName,
|
|
161
|
+
timestamp: new Date(),
|
|
162
|
+
user: { _id: user._id, username: user.username },
|
|
163
|
+
version: 1,
|
|
164
|
+
operation: 'create',
|
|
165
|
+
snapshot: doc // Pour la création, on stocke un snapshot complet
|
|
166
|
+
});
|
|
167
|
+
logger.debug(`History v1 (create) created for ${modelName} document ${doc._id}`);
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.error("History Module (OnDataAdded) Error:", error);
|
|
171
|
+
}
|
|
172
|
+
}, "event", "system");
|
|
173
|
+
|
|
174
|
+
// --- Écouteur pour la MODIFICATION de données (Versions > 1 - Diff) ---
|
|
175
|
+
Event.Listen("OnDataEdited", async (engine, { modelName, user, before, after }) => {
|
|
176
|
+
try {
|
|
177
|
+
const model = await getModel(modelName, user);
|
|
178
|
+
if (!model?.history?.enabled) return;
|
|
179
|
+
|
|
180
|
+
// Détermine les champs à historiser. Si non spécifié, tous les champs le sont.
|
|
181
|
+
const historizedFields = model.history.fields
|
|
182
|
+
? Object.keys(model.history.fields).filter(f => model.history.fields[f] === true)
|
|
183
|
+
: model.fields.map(f => f.name);
|
|
184
|
+
|
|
185
|
+
if (historizedFields.length === 0) return; // Pas de champs à suivre
|
|
186
|
+
|
|
187
|
+
const historyCollection = getCollection('history');
|
|
188
|
+
|
|
189
|
+
for (const afterDoc of after) {
|
|
190
|
+
const beforeDoc = before.find(b => b._id.toString() === afterDoc._id.toString());
|
|
191
|
+
if (!beforeDoc) {
|
|
192
|
+
logger.warn(`History Module: Could not find 'before' state for document ${afterDoc._id}. Skipping history record.`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const changes = calculateDiff(beforeDoc, afterDoc, historizedFields);
|
|
197
|
+
|
|
198
|
+
// S'il n'y a aucun changement sur les champs surveillés, on ne crée pas d'entrée.
|
|
199
|
+
if (!changes) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Récupérer la dernière version pour incrémenter
|
|
204
|
+
const lastVersionDoc = await historyCollection.findOne({ documentId: afterDoc._id }, { sort: { version: -1 } });
|
|
205
|
+
const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 2; // v1 est la création
|
|
206
|
+
|
|
207
|
+
await historyCollection.insertOne({
|
|
208
|
+
documentId: afterDoc._id,
|
|
209
|
+
model: modelName,
|
|
210
|
+
timestamp: new Date(),
|
|
211
|
+
user: { _id: user._id, username: user.username },
|
|
212
|
+
version: newVersion,
|
|
213
|
+
operation: 'update',
|
|
214
|
+
changes: changes // On stocke uniquement les différences
|
|
215
|
+
});
|
|
216
|
+
logger.debug(`History v${newVersion} (update) created for ${modelName} document ${afterDoc._id}`);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.error("History Module (OnDataEdited) Error:", error);
|
|
220
|
+
}
|
|
221
|
+
}, "event", "system");
|
|
222
|
+
|
|
223
|
+
// --- Écouteur pour la SUPPRESSION de données (Snapshot final) ---
|
|
224
|
+
Event.Listen("OnDataDeleted", async (engine, { modelName, user, before }) => {
|
|
225
|
+
// 'before' est un tableau des documents complets juste avant leur suppression.
|
|
226
|
+
try {
|
|
227
|
+
const model = await getModel(modelName, user);
|
|
228
|
+
if (!model?.history?.enabled) return;
|
|
229
|
+
|
|
230
|
+
const historyCollection = getCollection('history');
|
|
231
|
+
|
|
232
|
+
for (const deletedDoc of before) {
|
|
233
|
+
// Récupérer la dernière version pour incrémenter
|
|
234
|
+
const lastVersionDoc = await historyCollection.findOne({ documentId: deletedDoc._id }, { sort: { version: -1 } });
|
|
235
|
+
// Si aucune version n'existe, c'est peut-être un cas où l'historique a été activé après la création.
|
|
236
|
+
// On commence à 1, sinon on incrémente.
|
|
237
|
+
const newVersion = lastVersionDoc ? lastVersionDoc.version + 1 : 1;
|
|
238
|
+
|
|
239
|
+
await historyCollection.insertOne({
|
|
240
|
+
documentId: deletedDoc._id,
|
|
241
|
+
model: modelName,
|
|
242
|
+
timestamp: new Date(),
|
|
243
|
+
user: { _id: user._id, username: user.username },
|
|
244
|
+
version: newVersion,
|
|
245
|
+
operation: 'delete',
|
|
246
|
+
// On stocke un snapshot final du document supprimé pour audit ou restauration.
|
|
247
|
+
snapshot: deletedDoc
|
|
248
|
+
});
|
|
249
|
+
logger.debug(`History v${newVersion} (delete) created for ${modelName} document ${deletedDoc._id}`);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
logger.error("History Module (OnDataDeleted) Error:", error);
|
|
253
|
+
}
|
|
254
|
+
}, "event", "system");
|
|
255
|
+
|
|
256
|
+
logger.info("History module initialized and listening for data events.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Purge (supprime définitivement) des documents et tout leur historique associé.
|
|
262
|
+
* C'est une opération destructive à utiliser avec précaution.
|
|
263
|
+
* @param {object} user - L'utilisateur effectuant l'opération (pour les permissions).
|
|
264
|
+
* @param {string} modelName - Le nom du modèle concerné.
|
|
265
|
+
* @param {object} filter - Le filtre MongoDB pour trouver les documents à purger.
|
|
266
|
+
* @returns {Promise<{success: boolean, purgedCount: number, historyPurgedCount: number, error?: string}>}
|
|
267
|
+
*/
|
|
268
|
+
export async function purgeData(user, modelName = null, filter=null) {
|
|
269
|
+
const logger = new Logger("purgeData");
|
|
270
|
+
try {
|
|
271
|
+
const dataCollection = await getCollectionForUser(user);
|
|
272
|
+
const historyCollection = getCollection('history');
|
|
273
|
+
|
|
274
|
+
let m = modelName || { _model: modelName };
|
|
275
|
+
const f= filter || { _user: user.username };
|
|
276
|
+
// 1. Trouver les documents à purger pour récupérer leurs IDs
|
|
277
|
+
const docsToPurge = await dataCollection.find({ ...m, ...f }).project({ _id: 1 }).toArray();
|
|
278
|
+
if (docsToPurge.length === 0) {
|
|
279
|
+
return { success: true, purgedCount: 0, historyPurgedCount: 0 };
|
|
280
|
+
}
|
|
281
|
+
const docIdsToPurge = docsToPurge.map(d => d._id);
|
|
282
|
+
|
|
283
|
+
// 2. Purger l'historique associé à ces documents
|
|
284
|
+
const historyResult = await historyCollection.deleteMany({ documentId: { $in: docIdsToPurge } });
|
|
285
|
+
|
|
286
|
+
// 3. Purger les documents eux-mêmes
|
|
287
|
+
const dataResult = await dataCollection.deleteMany({ _id: { $in: docIdsToPurge } });
|
|
288
|
+
|
|
289
|
+
logger.info(`Purged ${dataResult.deletedCount} documents and ${historyResult.deletedCount} history entries for model '${modelName}'.`);
|
|
290
|
+
|
|
291
|
+
// On pourrait aussi émettre un événement "OnDataPurged" ici si nécessaire
|
|
292
|
+
await Event.Trigger("OnDataPurged", { user, modelName, purgedIds: docIdsToPurge });
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
purgedCount: dataResult.deletedCount,
|
|
297
|
+
historyPurgedCount: historyResult.deletedCount
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logger.error(`Error during data purge for model '${modelName}':`, error);
|
|
302
|
+
return { success: false, purgedCount: 0, historyPurgedCount: 0, error: error.message };
|
|
303
|
+
}
|
|
304
|
+
}
|