data-primals-engine 1.2.3 → 1.2.5

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 (58) hide show
  1. package/CONTRIBUTING.md +91 -0
  2. package/README.md +50 -22
  3. package/client/src/App.jsx +0 -5
  4. package/client/src/App.scss +6 -0
  5. package/client/src/ConditionBuilder.scss +34 -1
  6. package/client/src/ConditionBuilder2.jsx +179 -53
  7. package/client/src/ContentView.jsx +0 -3
  8. package/client/src/CronBuilder.jsx +0 -1
  9. package/client/src/CronPartBuilder.jsx +0 -2
  10. package/client/src/DashboardView.jsx +0 -5
  11. package/client/src/DataEditor.jsx +8 -10
  12. package/client/src/DataImporter.jsx +469 -0
  13. package/client/src/DataLayout.jsx +0 -1
  14. package/client/src/DataTable.jsx +2 -368
  15. package/client/src/DataTable.scss +18 -1
  16. package/client/src/Field.jsx +85 -48
  17. package/client/src/FlexBuilder.jsx +1 -1
  18. package/client/src/ModelCreator.jsx +29 -25
  19. package/client/src/ModelCreator.scss +13 -0
  20. package/client/src/ModelCreatorField.jsx +1 -5
  21. package/client/src/RTE.jsx +1 -6
  22. package/client/src/RTETrans.jsx +0 -2
  23. package/client/src/RelationField.jsx +1 -1
  24. package/client/src/RelationValue.jsx +1 -2
  25. package/client/src/TourSpotlight.jsx +0 -2
  26. package/client/src/filter.js +87 -0
  27. package/client/src/hooks/data.js +1 -3
  28. package/client/src/hooks/useTutorials.jsx +0 -1
  29. package/client/src/translations.js +60 -26
  30. package/package.json +4 -3
  31. package/server.js +2 -2
  32. package/src/data.js +8 -0
  33. package/src/email.js +2 -2
  34. package/src/engine.js +59 -20
  35. package/src/index.js +1 -1
  36. package/src/middlewares/middleware-mongodb.js +0 -1
  37. package/src/modules/assistant.js +1 -3
  38. package/src/modules/bucket.js +3 -4
  39. package/src/modules/data/data.core.js +17 -0
  40. package/src/modules/{data.js → data/data.js} +4595 -5991
  41. package/src/modules/data/data.routes.js +1637 -0
  42. package/src/modules/data/index.js +1 -0
  43. package/src/modules/file.js +1 -1
  44. package/src/modules/mongodb.js +0 -1
  45. package/src/modules/user.js +1 -1
  46. package/src/modules/workflow.js +38 -38
  47. package/src/packs.js +4 -1
  48. package/test/data.backup.integration.test.js +4 -5
  49. package/test/data.integration.test.js +2 -6
  50. package/test/events.test.js +1 -1
  51. package/test/file.test.js +1 -4
  52. package/test/import_export.integration.test.js +22 -15
  53. package/test/model.integration.test.js +8 -10
  54. package/test/user.test.js +2 -2
  55. package/test/vm.test.js +1 -1
  56. package/test/workflow.integration.test.js +17 -14
  57. package/test/workflow.robustness.test.js +15 -10
  58. package/src/modules/test +0 -147
@@ -0,0 +1,1637 @@
1
+ import {Logger} from "../../gameObject.js";
2
+ import {BSON, ObjectId} from "mongodb";
3
+ import * as util from 'node:util';
4
+ import {setTimeoutMiddleware} from '../../middlewares/timeout.js';
5
+ import {isDemoUser, isLocalUser} from "../../data.js";
6
+ import {
7
+ install, maxBytesPerSecondThrottleData,
8
+ maxMagnetsDataPerModel,
9
+ maxMagnetsModels,
10
+ maxModelsPerUser
11
+ } from "../../constants.js";
12
+ import {
13
+ datasCollection, filesCollection,
14
+ getCollection,
15
+ getCollectionForUser,
16
+ isObjectId,
17
+ modelsCollection
18
+ } from "../mongodb.js";
19
+ import {uuidv4} from "../../core.js";
20
+ import {Event} from "../../events.js";
21
+ import fs from "node:fs";
22
+ import i18n from "../../i18n.js";
23
+ import {
24
+ triggerWorkflows
25
+ } from "../workflow.js";
26
+ import {openaiJobModel} from "../../openai.jobs.js";
27
+ import {fileURLToPath} from 'url';
28
+ import {removeFile} from "../file.js";
29
+ import {getS3Stream, getUserS3Config} from "../bucket.js";
30
+ import {
31
+ generateLimiter,
32
+ hasPermission,
33
+ middlewareAuthenticator,
34
+ userInitiator
35
+ } from "../user.js";
36
+ import {assistantGlobalLimiter} from "../assistant.js";
37
+ import {Config} from "../../config.js";
38
+ import {processFilterPlaceholders} from "../../../client/src/filter.js";
39
+ import {tutorialsConfig} from "../../../client/src/tutorials.js";
40
+ import {
41
+ cancelAlerts, deleteData,
42
+ dumpUserData,
43
+ editData, exportData,
44
+ getModel, getResource,
45
+ handleCustomEndpointRequest,
46
+ handleDemoInitialization, importData, insertData, installPack,
47
+ patchData, searchData, validateModelStructure
48
+ } from "./data.js";
49
+ import process from "node:process";
50
+ import {throttleMiddleware} from "../../middlewares/throttle.js";
51
+ import {importJobs, modelsCache} from "./data.core.js";
52
+
53
+ let logger;
54
+
55
+ const sseConnections = new Map();
56
+
57
+ const throttle = throttleMiddleware(maxBytesPerSecondThrottleData);
58
+
59
+
60
+
61
+
62
+ async function logApiRequest(req, res, user, startTime, responseBody = null, error = null) {
63
+ const endTime = process.hrtime(startTime);
64
+ const latencyMs = (endTime[0] * 1e3 + endTime[1] * 1e-6); // Calculer la latence en ms
65
+
66
+ const headers = req.headers;
67
+ delete headers['Cookie'];
68
+ delete headers['Authorization'];
69
+
70
+ // 1. Préparer l'objet de données pour le modèle 'request'
71
+ const logEntryData = {
72
+ // Champs requis
73
+ timestamp: new Date(),
74
+ method: req.method,
75
+ url: req.originalUrl || req.url, // Utiliser originalUrl si disponible (Express)
76
+ status: res.statusCode,
77
+ latencyMs: parseFloat(latencyMs.toFixed(3)), // Arrondir et convertir en nombre
78
+
79
+ // Champs optionnels
80
+ ip: req.clientIp.substring('::ffff:'.length) || req.clientIp, // Obtenir l'IP du client
81
+ //requestHeaders: JSON.stringify(req.headers).substring(0, maxStringLength), // Optionnel: Peut être volumineux
82
+ requestBody: req.fields,
83
+ responseBody: res.statusCode >= 400 && responseBody ? JSON.stringify(responseBody).substring(0, maxStringLength) : null, // Optionnel: Peut être volumineux
84
+ error: error ? String(error.message || error) : null // Message d'erreur si applicable
85
+ };
86
+
87
+ try {
88
+ // 2. Appeler insertData pour enregistrer le log
89
+ // - 'request' est le nom du modèle
90
+ // - logEntryData contient les données
91
+ // - [] car pas de fichiers associés à ce log
92
+ // - null pour l'utilisateur (le log est système) ou 'user' si tu veux lier l'action à l'utilisateur
93
+ // - false pour ne pas bypasser la validation (important !)
94
+ const result = await insertData('request', logEntryData, [], user._user ? { username: user._user } : user, false);
95
+
96
+ if (result.success) {
97
+ console.log(`[API Log] Request logged successfully. ID: ${result.insertedIds?.join(', ')}`);
98
+ } else {
99
+ // Gérer l'échec de l'insertion du log (ne devrait pas bloquer la réponse principale)
100
+ console.error(`[API Log] Failed to log request: ${result.error}`);
101
+ }
102
+ } catch (insertError) {
103
+ // Gérer les erreurs inattendues lors de l'insertion
104
+ console.error(`[API Log] Unexpected error during logging: ${insertError.message}`, insertError.stack);
105
+ }
106
+ }
107
+
108
+
109
+ const middlewareLogger = async (req, res, next) => {
110
+ const startTime = process.hrtime();
111
+ let responseBodyChunk = null; // Pour capturer le corps de la réponse si nécessaire
112
+
113
+ //
114
+ //Optionnel: Capturer le corps de la réponse (peut impacter la performance)
115
+ const originalSend = res.send;
116
+ res.send = function (body) {
117
+ responseBodyChunk = body; // Capture le corps avant l'envoi
118
+ originalSend.call(this, body);
119
+ };
120
+
121
+ res.on('finish', async () => {
122
+ try {
123
+ await logApiRequest(req, res, req.me, startTime, ( !req.hideApiLogs ) ? JSON.parse(responseBodyChunk) : { message: "The request log has been encrypted because of a clear password in the request."}, res.locals.error);
124
+ } catch (e) {
125
+
126
+ }
127
+ });
128
+
129
+ res.on('error', async (err) => {
130
+ // Logger aussi en cas d'erreur avant 'finish'
131
+ try{
132
+ await logApiRequest(req, res, req.me, startTime, null, err);
133
+ } catch (e) {
134
+
135
+ }
136
+ });
137
+
138
+ next();
139
+ };
140
+
141
+ /**
142
+ * Envoie un SSE à un utilisateur spécifique.
143
+ * @param {string} username - Le nom de l'utilisateur à qui envoyer l'événement.
144
+ * @param {object} data - L'objet de données à envoyer.
145
+ * @returns {boolean} - True si l'événement a été envoyé, false sinon.
146
+ */
147
+ export function sendSseToUser(username, data) {
148
+ const res = sseConnections.get(username);
149
+ if (res) {
150
+ const ssePlugin = Event.Trigger("sendSseToUser", "system", "calls", data) || data;
151
+ res.write(`data: ${JSON.stringify(ssePlugin)}\n\n`);
152
+ return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+
158
+ export async function registerRoutes(engine){
159
+
160
+ let userMiddlewares = await engine.userProvider.getMiddlewares();
161
+
162
+ logger = engine.getComponent(Logger);
163
+
164
+ engine.all('/api/actions/:path', [middlewareAuthenticator, userInitiator], handleCustomEndpointRequest);
165
+ engine.post('/api/demo/initialize', [middlewareAuthenticator, userInitiator], handleDemoInitialization);
166
+
167
+ engine.post('/api/magnets', [middlewareAuthenticator, userInitiator], async (req, res) => {
168
+ const user = req.me;
169
+ const { name, description, modelNames } = req.fields; // Noms des modèles à inclure
170
+
171
+ if (!name || !Array.isArray(modelNames) || modelNames.length === 0) {
172
+ return res.status(400).json({ error: 'Name and a list of model names are required.' });
173
+ }
174
+
175
+ try {
176
+ const magnetId = new ObjectId(); // ID unique pour ce groupe de données/modèles
177
+ const magnetUuid = uuidv4(); // UUID pour le lien public
178
+
179
+ const modelsToCopy = await modelsCollection.find({
180
+ name: { $in: modelNames },
181
+ _user: user.username
182
+ }).limit(maxMagnetsModels).toArray();
183
+
184
+ if (modelsToCopy.length !== modelNames.length) {
185
+ return res.status(404).json({ error: "One or more specified models were not found." });
186
+ }
187
+
188
+ // 1. Copier les modèles et les marquer avec le magnetId
189
+ const newModels = modelsToCopy.map(m => {
190
+ const { _id, ...modelData } = m;
191
+ return {
192
+ ...modelData,
193
+ magnetId: magnetId.toString(),
194
+ locked: true, // Les modèles d'un magnet sont en lecture seule
195
+ _user: null // Le propriétaire est le magnet, pas un utilisateur
196
+ };
197
+ });
198
+ await modelsCollection.insertMany(newModels);
199
+
200
+ const coll = await getCollectionForUser(user);
201
+ // 2. Copier les données associes marquer
202
+ // C'est la partie la plus complexe à cause des relations
203
+ const idMap = {}; // Map: old_id -> new_id
204
+ for (const model of modelsToCopy) {
205
+ const datasToCopy = await coll.find({
206
+ _model: model.name,
207
+ _user: user.username
208
+ }).limit(maxMagnetsDataPerModel).toArray();
209
+
210
+ if (datasToCopy.length === 0) continue;
211
+
212
+ // Passe 1: Insérer les documents sans leurs relations pour obtenir les nouveaux IDs
213
+ const newDatas = datasToCopy.map(d => {
214
+ const { _id, ...data } = d;
215
+ idMap[d._id.toString()] = new ObjectId(); // Pré-générer le nouvel ID
216
+ return {
217
+ ...data,
218
+ _id: idMap[d._id.toString()],
219
+ magnetId: magnetId.toString(),
220
+ _user: null
221
+ };
222
+ });
223
+ const coll = await getCollectionForUser(user);
224
+ await coll.insertMany(newDatas);
225
+
226
+ // Passe 2: Mettre à jour les relations dans les documents fraîchement copiés
227
+ for (const newDoc of newDatas) {
228
+ const updatePayload = {};
229
+ for (const field of model.fields) {
230
+ if (field.type === 'relation' && newDoc[field.name]) {
231
+ if (Array.isArray(newDoc[field.name])) {
232
+ newDoc[field.name] = newDoc[field.name]
233
+ .map(oldId => idMap[oldId.toString()]?.toString())
234
+ .filter(Boolean);
235
+ } else {
236
+ newDoc[field.name] = idMap[newDoc[field.name].toString()]?.toString() || null;
237
+ }
238
+ updatePayload[field.name] = newDoc[field.name];
239
+ }
240
+ }
241
+ if (Object.keys(updatePayload).length > 0) {
242
+ const coll = await getCollectionForUser(user);
243
+ await coll.updateOne({ _id: newDoc._id }, { $set: updatePayload });
244
+ }
245
+ }
246
+ }
247
+
248
+ // 3. Créer l'enregistrement du magnet lui-même
249
+ const magnetData = {
250
+ uuid: magnetUuid,
251
+ name,
252
+ description,
253
+ owner: user.username,
254
+ createdAt: new Date(),
255
+ models: modelNames,
256
+ _id: magnetId
257
+ };
258
+ await getCollection('magnets').insertOne(magnetData); // Nouvelle collection 'magnets'
259
+
260
+ res.status(201).json({
261
+ success: true,
262
+ message: "Magnet link created successfully!",
263
+ url: `https://data.primals.net/magnet/${magnetUuid}` // ou votre URL de dev
264
+ });
265
+
266
+ } catch (error) {
267
+ logger.error("Error creating magnet link:", error);
268
+ res.status(500).json({ error: "An internal server error occurred." });
269
+ }
270
+ });
271
+
272
+ /**
273
+ * Route pour vérifier si une condition de complétion est remplie.
274
+ * Utilise la fonction `searchData` existante pour interroger les données.
275
+ */
276
+ engine.post('/api/tutorials/check-completion', middlewareAuthenticator, async (req, res) => {
277
+ try {
278
+ const { model, filter, limit } = req.fields;
279
+ const user = req.me;
280
+
281
+ if (!model || !filter || limit === undefined) {
282
+ return res.status(400).json({ error: 'Payload de condition de complétion invalide.' });
283
+ }
284
+
285
+ const processedFilter = processFilterPlaceholders(filter, user);
286
+
287
+ // On utilise la fonction de recherche interne de l'application
288
+ const searchResult = await searchData({
289
+ model,
290
+ filter: processedFilter,
291
+ limit, // Optimisation : pas besoin de plus de résultats
292
+ page: 1
293
+ }, user);
294
+
295
+ // searchData devrait renvoyer un `count` total des documents correspondants
296
+ const isCompleted = searchResult.count >= limit;
297
+
298
+ res.json({ isCompleted });
299
+
300
+ } catch (error) {
301
+ console.error('[Tutoriel Check Error]', error);
302
+ res.status(500).json({ error: 'Erreur lors de la vérification de la complétion.', details: error.message });
303
+ }
304
+ });
305
+
306
+
307
+
308
+ engine.post('/api/tutorials/set-active', middlewareAuthenticator, async (req, res) => {
309
+ try {
310
+ const { tutorialState } = req.fields;
311
+ const user = req.me;
312
+
313
+ if (tutorialState !== null && (typeof tutorialState !== 'object' || !tutorialState.id)) {
314
+ return res.status(400).json({ error: 'Invalid tutorial state payload.' });
315
+ }
316
+
317
+ // Créer une représentation de l'utilisateur mis à jour pour la réponse
318
+ const updatedData = { activeTutorial: tutorialState };
319
+
320
+ await engine.userProvider.updateUser(user, updatedData);
321
+
322
+ // --- MODIFICATION ---
323
+ res.json({
324
+ success: true,
325
+ updatedData // Renvoyer l'objet utilisateur complet
326
+ });
327
+
328
+ } catch (error) {
329
+ console.error('[Tutoriel Set Active Error]', error);
330
+ res.status(500).json({ error: 'Error setting active tutorial.', details: error.message });
331
+ }
332
+ });
333
+
334
+ // ...
335
+
336
+ engine.post('/api/tutorials/:tutorialId/claim-rewards', middlewareAuthenticator, async (req, res) => {
337
+ try {
338
+ const { tutorialId } = req.params;
339
+ const user = req.me; // L'objet utilisateur est déjà chargé
340
+ const tutorial = tutorialsConfig.find(t => t.id === tutorialId);
341
+
342
+ if (!tutorial) return res.status(404).json({ error: 'Tutoriel non trouvé.' });
343
+ if (!tutorial.rewards) return res.status(400).json({ error: 'Ce tutoriel n\'a pas de récompenses.' });
344
+ if (user.completedTutorials?.includes(tutorialId)) {
345
+ return res.status(400).json({ error: 'Tutoriel déjà terminé.' });
346
+ }
347
+
348
+ const { xpBonus, skill, achievement, notification } = tutorial.rewards;
349
+
350
+ // --- LOGIQUE CORRIGÉE ---
351
+ let newData= {};
352
+ newData.xp = newData.xp || 0;
353
+ newData.achievements = newData.achievements || [];
354
+ newData.skills = newData.skills || [];
355
+ newData.completedTutorials = newData.completedTutorials || [];
356
+
357
+ // Appliquer les récompenses directement sur l'objet newData
358
+ if (xpBonus) newData.xp += xpBonus;
359
+ if (achievement && !newData.achievements.includes(achievement)) newData.achievements.push(achievement);
360
+ if (skill) {
361
+ const existingSkill = newData.skills.find(s => s.name === skill.name);
362
+ if (existingSkill) {
363
+ existingSkill.points += skill.points;
364
+ } else {
365
+ newData.skills.push({ name: skill.name, points: skill.points });
366
+ }
367
+ }
368
+
369
+ newData.completedTutorials.push(tutorialId);
370
+ newData.activeTutorial = null;
371
+
372
+ await engine.userProvider.updateUser(user, newData);
373
+
374
+ const translatedNotification = {
375
+ title: i18n.t(notification.title, notification.title),
376
+ message: i18n.t(notification.message, notification.message)
377
+ };
378
+
379
+ res.json({
380
+ success: true,
381
+ userUpdate: newData, // Renvoyer l'objet utilisateur complet et mis à jour
382
+ notification: translatedNotification
383
+ });
384
+
385
+ } catch (error) {
386
+ console.error('[Tutoriel Rewards Error]', error);
387
+ res.status(500).json({ error: 'Erreur lors de l\'attribution des récompenses.', details: error.message });
388
+ }
389
+ });
390
+
391
+ engine.get('/api/import/progress/:jobId', middlewareAuthenticator, async (req, res) => {
392
+ const { jobId } = req.params;
393
+ const user = req.me;
394
+
395
+ // Vérification d'autorisation: s'assurer que l'utilisateur est bien le propriétaire de la tâche
396
+ if (!importJobs[jobId] || importJobs[jobId].userId !== user.username) {
397
+ return res.status(403).send('Forbidden');
398
+ }
399
+
400
+ res.setHeader('Content-Type', 'text/event-stream');
401
+ res.setHeader('Cache-Control', 'no-cache');
402
+ res.setHeader('Connection', 'keep-alive');
403
+ res.setHeader('X-Accel-Buffering', 'no'); // Important pour désactiver le buffering de certains proxys (ex: Nginx)
404
+
405
+ const sendProgress = () => {
406
+ const job = importJobs[jobId];
407
+ if (job) {
408
+ res.write(`data: ${JSON.stringify(job)}\n\n`);
409
+ if (job.status === 'completed' || job.status === 'failed') {
410
+ res.end(); // Terminer le stream lorsque la tâche est terminée
411
+ delete importJobs[jobId]; // Nettoyer la tâche de la mémoire
412
+ }
413
+ } else {
414
+ // Si la tâche n'est plus là (déjà nettoyée ou jamais existé)
415
+ res.write(`data: ${JSON.stringify({ status: 'not_found', message: 'Job not found or already completed.' })}\n\n`);
416
+ res.end();
417
+ }
418
+ };
419
+
420
+ // Envoyer la progression initiale immédiatement
421
+ sendProgress();
422
+
423
+ // Mettre en place un intervalle pour envoyer des mises à jour régulières
424
+ // Pour une application plus complexe, vous pourriez utiliser un EventEmitter
425
+ // pour déclencher des mises à jour uniquement quand la progression change.
426
+ const intervalId = setInterval(sendProgress, 1000); // Mettre à jour toutes les secondes
427
+
428
+ // Nettoyer l'intervalle lorsque le client se déconnecte
429
+ req.on('close', () => {
430
+ clearInterval(intervalId);
431
+ logger.debug(`SSE client disconnected for job ${jobId}`);
432
+ });
433
+ });
434
+
435
+ engine.get('/api/alerts/subscribe', [middlewareAuthenticator], (req, res) => {
436
+ const user = req.me;
437
+
438
+ // Configuration des headers pour une connexion SSE
439
+ res.setHeader('Content-Type', 'text/event-stream');
440
+ res.setHeader('Cache-Control', 'no-cache');
441
+ res.setHeader('Connection', 'keep-alive');
442
+ res.setHeader('X-Accel-Buffering', 'no'); // Important pour Nginx
443
+
444
+ // Stocker la connexion de l'utilisateur
445
+ sseConnections.set(user.username, res);
446
+ logger.info(`User ${user.username} subscribed to SSE alerts.`);
447
+
448
+ // Envoyer un message de confirmation
449
+ res.write(`data: ${JSON.stringify({ type: 'connection_established', message: 'Subscribed to alerts.' })}\n\n`);
450
+
451
+ // Gérer la déconnexion du client
452
+ req.on('close', () => {
453
+ sseConnections.delete(user.username);
454
+ logger.info(`User ${user.username} disconnected from SSE alerts.`);
455
+ });
456
+ });
457
+
458
+ engine.post('/api/data/import', [middlewareAuthenticator, userInitiator, ...userMiddlewares, setTimeoutMiddleware(60000)], async (req, res) => {
459
+ // ... (vérifications de permissions existantes) ...
460
+ const result = await importData(req.fields, req.files, req.me);
461
+ if( result.success ){
462
+ res.status(202).json(result);
463
+ }else{
464
+ res.status(500).json(result);
465
+ }
466
+ });
467
+
468
+
469
+ engine.post('/api/model/search', [middlewareAuthenticator, userInitiator], async (req, res) => {
470
+ const { query } = req.fields;
471
+ const user = req.me;
472
+
473
+ if (!query || typeof query !== 'string') {
474
+ return res.status(400).json({ success: false, error: 'A search query string is required.' });
475
+ }
476
+
477
+ try {
478
+ // Utilise une expression régulière pour une recherche insensible à la casse
479
+ const searchRegex = new RegExp(query, 'i');
480
+
481
+ const results = await modelsCollection.find({
482
+ // Cherche dans les modèles de l'utilisateur OU les modèles partagés
483
+ $or: [
484
+ { _user: user.username },
485
+ { _user: { $exists: false } }
486
+ ],
487
+ // Cherche dans le nom OU la description
488
+ $and: [
489
+ { $or: [
490
+ { name: { $regex: searchRegex } },
491
+ { description: { $regex: searchRegex } }
492
+ ]}
493
+ ]
494
+ }, {
495
+ // On ne retourne que les informations utiles pour l'IA, pas tout le modèle
496
+ projection: {
497
+ name: 1,
498
+ description: 1,
499
+ _id: 0
500
+ }
501
+ }).limit(10).toArray(); // Limite à 10 résultats pour ne pas surcharger
502
+
503
+ res.json({ success: true, models: results });
504
+
505
+ } catch (error) {
506
+ logger.error(`[Model Search] Error searching models for query "${query}":`, error);
507
+ res.status(500).json({ success: false, error: 'An internal server error occurred.' });
508
+ }
509
+ });
510
+
511
+ engine.post('/api/model/generate', [middlewareAuthenticator, userInitiator, assistantGlobalLimiter, generateLimiter, setTimeoutMiddleware(30000)], async (req, res) => {
512
+ // --- NOUVELLE LOGIQUE : Accepter le prompt ET un modèle existant ---
513
+ const { prompt, history = [], existingModel } = req.fields;
514
+ const user = req.me;
515
+ const lang = req.query.lang || 'en';
516
+
517
+ if (!prompt) {
518
+ return res.status(400).json({ error: 'Prompt is required' });
519
+ }
520
+
521
+ try {
522
+ const existingModelsCursor = modelsCollection.find({
523
+ $or: [
524
+ { _user: user.username },
525
+ { _user: { $exists: false } }
526
+ ]
527
+ });
528
+ const existingModels = await existingModelsCursor.toArray();
529
+ const modelNames = existingModels.map(m => m.name);
530
+
531
+ let finalPrompt = prompt; // Par défaut, on utilise le prompt de l'utilisateur
532
+
533
+ if (existingModel && typeof existingModel === 'object') {
534
+ // Si un modèle existant est fourni, on crée une instruction d'édition
535
+ // C'est l'injonction d'édition que vous souhaitiez.
536
+ logger.info(`[AI Edit] Génération d'une modification pour le modèle : ${existingModel.name}`);
537
+ finalPrompt = `Based on the user's request, improve or modify the following JSON model definition. The user request is: "${prompt}".\n\nHere is the original JSON model to edit:\n${JSON.stringify(existingModel, null, 2)}`;
538
+ } else {
539
+ logger.info(`[AI Create] Génération d'un nouveau modèle depuis le prompt.`);
540
+ }
541
+
542
+ // On passe le prompt final (soit de création, soit d'édition) à l'IA
543
+ const generatedModels = await openaiJobModel(lang, finalPrompt, history, modelNames);
544
+
545
+ if (typeof(generatedModels) !== 'object' || !generatedModels.models) {
546
+ console.error("La réponse de l'IA n'est pas un objet avec une clé 'models':", generatedModels);
547
+ return res.status(500).json({ success: false, error: "Erreur de format de réponse de l'IA." });
548
+ }
549
+
550
+ res.status(200).json(generatedModels.models);
551
+
552
+ } catch (error) {
553
+ console.error("Error during AI model generation:", error);
554
+ res.status(500).json({ error: "An error occurred while generating the model.", details: error.message });
555
+ }
556
+ });
557
+
558
+ engine.put('/api/model/:id', [middlewareAuthenticator, userInitiator, setTimeoutMiddleware(15000)], async (req, res) => {
559
+ const result = await editModel(req.me, req.params.id, req.fields);
560
+ if( result.success){
561
+ return res.status(result.statusCode || 200).json(result);
562
+ }else{
563
+ return res.status(result.statusCode || 500).json(result);
564
+ }
565
+ });
566
+
567
+ engine.post('/api/data/restore', [throttle, middlewareAuthenticator, userInitiator, ...userMiddlewares, setTimeoutMiddleware(60000)], async (req, res) => {
568
+
569
+ if (!((user?.roles || []).includes("admin"))) {
570
+ return res.status(403).json({success: false, error: 'Cannot backup data. Contact an administrator to get back your data'})
571
+ }
572
+
573
+ try {
574
+ await loadFromDump({username: req.query.restoredUser});
575
+ res.status(200).json({success: true});
576
+ } catch (e) {
577
+ logger.error(e);
578
+ res.status(500).json({success: false, error: e.message});
579
+ }
580
+ });
581
+
582
+ engine.post('/api/data/dump', [throttle, middlewareAuthenticator, ...userMiddlewares, setTimeoutMiddleware(60000)], async (req, res) => {
583
+
584
+ if (!((req.me?.roles || []).includes("admin"))) {
585
+ return res.status(403).json({success: false, error: 'Cannot dump data.'})
586
+ }
587
+
588
+ try {
589
+ if( typeof(req.query.restoredUser) === 'string' && req.query.restoredUser.length < 100 ) {
590
+ await dumpUserData({username: req.query.restoredUser});
591
+ res.status(200).json({success: true});
592
+ }else{
593
+ throw new Error("User restoredUser must be defined in the URL query params.")
594
+ }
595
+ } catch (e) {
596
+ logger.error(e);
597
+ res.status(500).json({success: false, error: e.message});
598
+ }
599
+ });
600
+
601
+ engine.post('/api/data', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, ...userMiddlewares, setTimeoutMiddleware(15000)], async (req, res) => {
602
+ const body = req.files ? req.fields : req.fields;
603
+ const modelName = body.model; // Les données à insérer/mettre à jour (assurez-vous de valider et nettoyer ces données côté client et serveur !)
604
+ const data = body.data || (body._data && JSON.parse(body._data));
605
+
606
+ try {
607
+ const model = await getModel(modelName, req.me);
608
+ if( model.fields.some(f => f.type ==='password'))
609
+ req.hideApiLogs = true;
610
+ const json = await insertData(modelName, data, req.files, req.me);
611
+ res.status(200).json(json);
612
+ } catch (e) {
613
+ res.status(400).json({ success: false, error: e.message });
614
+ }
615
+ });
616
+
617
+ engine.post('/api/data/search', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, ...userMiddlewares, setTimeoutMiddleware(30000)], async (req, res) => {
618
+ const { pack } = req.fields;
619
+
620
+ try {
621
+ const {data, count} = await searchData({...req.query, model: req.fields.model || req.query.model, filter: req.fields.filter, pack}, req.me);
622
+
623
+ if( req.query.attachment ) {
624
+ res.attachment(req.query.attachment);
625
+ res.json(data.map(d=>{
626
+ let fd = {...d};
627
+ Object.keys(d).forEach(di =>{
628
+ if (['_model', '_user'].includes(di)){
629
+ delete fd[di];
630
+ }
631
+ });
632
+ return fd;
633
+ }));
634
+ }else
635
+ {
636
+ res.json({data, count: count});
637
+ }
638
+ } catch (error) {
639
+ logger.error(error);
640
+ res.status(400).json({ success: false, error: error.message });
641
+ }
642
+ });
643
+
644
+ engine.delete('/api/data/:ids', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, setTimeoutMiddleware(15000)], async (req, res) => {
645
+ const ids = req.params.ids.split(',');
646
+ const r = await deleteData(req.fields.model, ids, req.me);
647
+ if( r.error) {
648
+ return res.status(r.statusCode || 400).json(r);
649
+ }else{
650
+ return res.status(r.statusCode || 200).json(r);
651
+ }
652
+ });
653
+
654
+ engine.delete('/api/data', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, setTimeoutMiddleware(15000)], async (req, res) => {
655
+ const r = await deleteData(req.fields.model, req.fields.filter, req.me);
656
+ if( r.error) {
657
+ return res.status(r.statusCode || 400).json(r);
658
+ }else{
659
+ return res.status(r.statusCode || 200).json(r);
660
+ }
661
+ });
662
+
663
+ // --- Export Endpoint ---
664
+ engine.post('/api/data/export', [middlewareAuthenticator, throttle, userInitiator, ...userMiddlewares, setTimeoutMiddleware(60000)], async (req, res) => {
665
+ try {
666
+ const results = await exportData({...req.fields, depth:req.query.depth, lang: req.query.lang}, req.me);
667
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
668
+ const filename = `export-${req.fields.models.join('-')}-${timestamp}.json`;
669
+
670
+ if (!results.success) {
671
+ res.status(400).json(results); // Renvoyer l'objet d'erreur complet
672
+ return;
673
+ }
674
+
675
+ const dataToSerialize = req.query.withModels ? {data: results.data, models: results.models } : results.data;
676
+
677
+ // --- La sérialisation est maintenant déléguée au worker ---
678
+ const jsonString = await runImportExportWorker('stringify-json', { data: dataToSerialize });
679
+
680
+ // On envoie directement la chaîne de caractères JSON, sans que Express ne la re-traite.
681
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
682
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
683
+ res.send(jsonString);
684
+
685
+ } catch (error) {
686
+ console.error("General Export Error:", error);
687
+ logger?.error("General Export Error:", error);
688
+ return res.status(500).json({ success: false, error: i18n.t('api.data.error', 'An error occurred while processing the request.') });
689
+ }
690
+ });
691
+
692
+ engine.patch('/api/data', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
693
+ const filter = req.fields.filter;
694
+ const hash = req.params.id; // Récupérer l'identifiant de la ressource à modifier
695
+ const data = req.fields.data || (req.fields._data && JSON.parse(req.fields._data));
696
+ const r = await patchData(req.fields.model, filter || hash, data, req.files, req.me);
697
+ if (r.error) {
698
+ res.status(400).json(r);
699
+ } else {
700
+ res.status(200).json(r);
701
+ }
702
+ });
703
+
704
+ engine.put('/api/data', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
705
+ try {
706
+ const filter = req.fields.filter;
707
+ const hash = req.params.id; // Récupérer l'identifiant de la ressource à modifier
708
+ const data = req.fields.data || (req.fields._data && JSON.parse(req.fields._data));
709
+ const r = await editData(req.fields.model, filter || hash, data, req.files, req.me)
710
+ if (r.error)
711
+ res.status(400).json(r);
712
+ else
713
+ res.status(200).json(r);
714
+ } catch (e) {
715
+ res.status(500).json({error: e.message});
716
+ }
717
+ });
718
+
719
+ engine.patch('/api/data/:id', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
720
+ const filter = req.fields.filter;
721
+ const hash = req.params.id; // Récupérer l'identifiant de la ressource à modifier
722
+ const data = req.fields.data || (req.fields._data && JSON.parse(req.fields._data));
723
+ const r = await patchData(req.fields.model, filter || hash, data, req.files, req.me);
724
+ if (r.error) {
725
+ res.status(400).json(r);
726
+ } else {
727
+ res.status(200).json(r);
728
+ }
729
+ });
730
+
731
+ engine.put('/api/data/:id', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
732
+ try {
733
+ const filter = req.fields.filter;
734
+ const hash = req.params.id; // Récupérer l'identifiant de la ressource à modifier
735
+ const data = req.fields.data || (req.fields._data && JSON.parse(req.fields._data));
736
+ const r = await editData(req.fields.model, filter || { "$eq": ["$_id", { "$toObjectId": hash}]}, data, req.files, req.me)
737
+ if (r.error)
738
+ res.status(400).json(r);
739
+ else
740
+ res.status(200).json(r);
741
+ } catch (e) {
742
+ res.status(500).json({error: e.message});
743
+ }
744
+ });
745
+
746
+ engine.get('/api/model', [throttle, middlewareAuthenticator, userInitiator,middlewareLogger], async (req, res) => {
747
+
748
+ // get by name
749
+ try {
750
+ const modelName = req.query.name; // Récupérer le nom du modèle depuis les paramètres de la requête
751
+ if (!modelName) {
752
+ return res.status(400).json({error: "Le paramètre 'name' est requis."});
753
+ }
754
+
755
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_GET_MODEL"], req.me) && !await hasPermission("API_GET_MODEL_"+modelName, req.me)){
756
+ return res.json({success: false, error: i18n.t('api.permission.getModel')})
757
+ }
758
+
759
+ const model = await getModel(modelName, req.me);
760
+ res.json(model);
761
+ } catch (error) {
762
+ logger.error(error);
763
+ res.status(404).json({ success: false, error: error.message });
764
+ }
765
+ });
766
+ engine.get('/api/models', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
767
+
768
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_GET_MODELS"], req.me)){
769
+ return res.status(403).json({success: false, error: i18n.t('api.permission.getModels')})
770
+ }
771
+
772
+ // get by name
773
+ try {
774
+ let models = await modelsCollection.find({$or: [{_user: {$exists: false}}]})
775
+ .sort({_user:-1, _id: 1 }).limit(maxModelsPerUser).toArray();
776
+ models = models
777
+ .concat(
778
+ await modelsCollection.find({$or: [{_user: req.me._user}, {_user: req.me.username}]})
779
+ .sort({_user:-1, _id: 1 })
780
+ .limit(maxModelsPerUser).toArray());
781
+ res.json(models);
782
+ } catch (error) {
783
+ logger.error(error);
784
+ res.status(500).json({ success: false, error: error.message });
785
+ }
786
+ });
787
+ engine.post('/api/model', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, ...userMiddlewares], async (req, res) => {
788
+
789
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_ADD_MODEL"], req.me) ){
790
+ return res.status(403).json({success: false, error: i18n.t('api.permission.addModel')})
791
+ }
792
+ try {
793
+ const modelData = req.fields;
794
+ validateModelStructure(modelData);
795
+
796
+
797
+ const existingModel = await modelsCollection.findOne({name: modelData.name, $and: [{_user: {$exists: true}}, {$or: [{_user: req.me._user}, {_user: req.me.username}]}] });
798
+ if (existingModel) {
799
+ /*await modelsCollection.updateOne({name: modelData.name,_user: req.query._user }, { $set: modelData });
800
+ res.status(200).json({updated: true});*/
801
+ res.status(400).json({ success: false, msg: i18n.t('api.model.alreadyExists') });
802
+ }else {
803
+ modelData._user = req.me._user || req.me.username;
804
+
805
+ const count = await modelsCollection.count({
806
+ $and: [{_user: {$exists: true}}, {_user: req.me.username}]
807
+ });
808
+ if( count < maxModelsPerUser) {
809
+ if(await engine.userProvider.hasFeature(req.me, 'indexes')){
810
+ for (const field of modelData.fields) {
811
+ if( field.index ) {
812
+ await datasCollection.createIndex({[field.name]: 1}, {
813
+ partialFilterExpression: {
814
+ _model: modelData.name,
815
+ _user: req.me.username
816
+ }
817
+ });
818
+ }
819
+ }
820
+ }
821
+ const result = await modelsCollection.insertOne(modelData);
822
+
823
+ const model = await modelsCollection.findOne({_id: result.insertedId });
824
+ triggerWorkflows(model, req.me, 'ModelAdded').catch(workflowError => {
825
+ logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
826
+ });
827
+
828
+ modelsCache.del(req.me.username+'@@'+modelData.name);
829
+
830
+ res.status(201).json({success: !!result.insertedId, insertedId: result.insertedId}); // 201 Created
831
+ }else{
832
+ res.status(400).json({success: false, msg: i18n.t('api.model.maxModels')});
833
+ }
834
+ }
835
+ } catch (error) {
836
+ logger.error(error);
837
+ res.status(400).json({success: false, error: error.message});
838
+ }
839
+ });
840
+
841
+ engine.post('/api/models/import', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger, ...userMiddlewares], async (req, res) => {
842
+
843
+ if( isLocalUser(req.me) && !await hasPermission(["API_ADMIN","API_IMPORT_MODEL"], req.me)){
844
+ return res.status(403).json({success: false, error: i18n.t('api.permission.importModels')})
845
+ }
846
+ try {
847
+ const modelsToImport = await modelsCollection.find({name: { $in: req.fields.models }, _user: { $exists: false } }).toArray();
848
+ const ids = [];
849
+
850
+ const getPromise = () => {
851
+ return Promise.allSettled(modelsToImport.map(model => modelsCollection.findOne({name: model.name, _user: req.me.username }).then(m => {
852
+ if( !m) {
853
+ return modelsCollection.insertOne({...model, _id: undefined, locked: undefined, fields: model.fields.map(f => ({...f, locked: undefined})), _user: req.me.username}).then(r =>{
854
+ if( r.insertedId ) ids.push(model.name);
855
+ })
856
+ }
857
+ return Promise.reject();
858
+ })));
859
+ }
860
+ const install = !!req.fields.install;
861
+ if( install && (isDemoUser(req.me) && Config.Get("useDemoAccounts")) ){
862
+
863
+ await datasCollection.deleteMany({ _user: req.me.username});
864
+ await modelsCollection.deleteMany({ _user: req.me.username});
865
+ const files = await filesCollection.find({ mainUser: req.me.username}).toArray();
866
+ try {
867
+ files.forEach(file =>removeFile(file.guid, req.me));
868
+ } catch (e) {
869
+
870
+ }
871
+
872
+ await cancelAlerts(req.me);
873
+
874
+ await getPromise();
875
+ Event.Trigger('jobAddUserData', req.me.username);
876
+ }else{
877
+ await getPromise();
878
+ }
879
+
880
+ if( ids.length > 0 )
881
+ res.status(201).json({ success: true, imported: ids }); // 201 Created
882
+ else
883
+ res.status(500).json({ success: false });
884
+ } catch (error) {
885
+ logger.error(error);
886
+ res.status(400).json({success: false, error, badRequest: true});
887
+ }
888
+ });
889
+
890
+ engine.delete('/api/model', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
891
+
892
+ const modelName = req.query.name;
893
+ if (!modelName) {
894
+ return res.status(400).json({error: "Le paramètre 'name' est requis."});
895
+ }
896
+
897
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && (
898
+ !await hasPermission(["API_ADMIN","API_DELETE_MODEL","API_DELETE_MODEL_"+modelName], req.me) ||
899
+ await hasPermission(["API_DELETE_MODEL_NOT_"+modelName], req.me))){
900
+ return res.status(403).json({success: false, error: i18n.t( "api.permission.deleteModel", { model: modelName})})
901
+ }
902
+
903
+ // delete the model (statut archivage + date butoire)
904
+ // error otherwise
905
+ try {
906
+ const model = await modelsCollection.findOne({name: modelName, $and: [{_user: {$exists: true}}, {$or: [{_user: req.me._user}, {_user: req.me.username}]}] });
907
+ if (!model) {
908
+ return res.status(404).json({error: i18n.t( "api.model.notFound", { model: modelName})});
909
+ }
910
+
911
+ if( await engine.userProvider.hasFeature(req.me, 'indexes') ) {
912
+ const indexes = await datasCollection.indexes();
913
+ for (const index of indexes) {
914
+ if (index.partialFilterExpression?._model === model.name &&
915
+ index.partialFilterExpression?._user === req.me.username) {
916
+ await datasCollection.dropIndex(index.name);
917
+ }
918
+ }
919
+ }
920
+
921
+ const filter= {name: modelName, $and: [{_user: {$exists: true}}, {$or: [{_user: req.me._user}, {_user: req.me.username}]}]};
922
+
923
+ const modelDeleted = await modelsCollection.findOne(filter);
924
+ triggerWorkflows(modelDeleted, req.me, 'ModelDeleted').catch(workflowError => {
925
+ logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${modelDeleted._model} ID ${modelDeleted._id}:`, workflowError);
926
+ });
927
+
928
+ modelsCache.del(req.me.username+'@@'+model.name);
929
+
930
+ const result = await modelsCollection.deleteOne(filter);
931
+ if( result )
932
+ res.status(200).json({success: true});
933
+ else
934
+ res.status(500).json({success: false, message: i18n.t('api.model.deleteFailed')});
935
+ } catch (error) {
936
+ // ... (gestion des erreurs)
937
+ logger.error(error);
938
+ }
939
+ });
940
+ engine.patch('/api/model/:modelId/renameField', [throttle, middlewareAuthenticator, userInitiator, middlewareLogger], async (req, res) => {
941
+
942
+ try {
943
+ const modelId = req.params.modelId;
944
+ const { oldFieldName, newFieldName } = req.fields;
945
+
946
+ // Basic validation
947
+ if (!oldFieldName || !newFieldName) {
948
+ return res.status(400).json({ error: '`oldFieldName` and `newFieldName` are required.' });
949
+ }
950
+
951
+ if( ["_hash", "_id", "_model", "_user"].includes(newFieldName) ){
952
+ return res.status(400).json({ error: 'Reserved field name : ' + newFieldName });
953
+ }
954
+
955
+ // Find the model
956
+ const model = await modelsCollection.findOne({ _id: new ObjectId(modelId) });
957
+ if (!model) {
958
+ return res.status(404).json({ error: i18n.t('api.model.notFound', { model: modelId }) });
959
+ }
960
+
961
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && (
962
+ !await hasPermission(["API_ADMIN", "API_EDIT_MODEL", "API_EDIT_MODEL_"+model.name], req.me) ||
963
+ await hasPermission(["API_EDIT_MODEL_NOT_"+model.name], req.me))){
964
+ return res.status(403).json({success: false, error: i18n.t('api.permission.editModel')})
965
+ }
966
+
967
+ // Check if old field exists
968
+ const oldField = model.fields.find(f => f.name === oldFieldName);
969
+ if (!oldField) {
970
+ return res.status(404).json({ error: `Field '${oldFieldName}' not found in the model.` });
971
+ }
972
+ const newField = model.fields.find(f => f.name === newFieldName);
973
+ if (newField) {
974
+ return res.status(404).json({ error: `A Field with the name '${newFieldName}' already exist in the model.` });
975
+ }
976
+ const result = await modelsCollection.updateOne({ _id: new ObjectId(modelId), 'fields.name': oldFieldName }, { $set: { 'fields.$.name': newFieldName } })
977
+
978
+ if (result.modifiedCount !== 1)
979
+ return res.status(404).json({ error: i18n.t('api.model.notFound', {model: model.name})});
980
+
981
+ const collection = await getCollectionForUser(req.me);
982
+
983
+ await collection.updateMany(
984
+ { _model: model.name },
985
+ { $rename: { [oldFieldName]: newFieldName } }
986
+ );
987
+
988
+ const set = {};
989
+ model.fields.forEach(f => {
990
+ if( f.name === oldFieldName ){
991
+ set[f.name] = newFieldName;
992
+ }
993
+ })
994
+ await modelsCollection.updateOne({ _id: new ObjectId(modelId) }, { $set: set });
995
+
996
+ modelsCache.del(req.me.username+'@@'+model.name);
997
+
998
+ res.json({ success: true, message: `Field '${oldFieldName}' renamed to '${newFieldName}' in model '${model.name}'.` });
999
+ } catch (error) {
1000
+ logger.error(error);
1001
+ res.status(500).json({ error: 'An error occurred while renaming the field.' });
1002
+ }
1003
+ });
1004
+
1005
+
1006
+ // Endpoint pour calculer la valeur d'UN KPI spécifique par son ID
1007
+ engine.get('/api/kpis/calculate/:id', [throttle, middlewareAuthenticator, userInitiator], async (req, res) => {
1008
+ const { id } = req.params;
1009
+
1010
+ if (!ObjectId.isValid(id)) {
1011
+ return res.status(400).json({ success: false, error: 'Invalid KPI ID format' });
1012
+ }
1013
+
1014
+ let kpiDef; // Déclarer en dehors pour e accessible dans le catch final
1015
+
1016
+ try { // <--- TRY PRINCIPAL
1017
+
1018
+ // 1. Récupérer la définition du KPI
1019
+ try {
1020
+ kpiDef = await datasCollection.findOne({ _id: new ObjectId(id), _model: 'kpi', _user: req.me._user || req.me.username });
1021
+ if (!kpiDef) {
1022
+ return res.status(404).json({ success: false, error: 'KPI definition not found' });
1023
+ }
1024
+ } catch (dbError) {
1025
+ console.error(">>> ERREUR LORS DE findOne KPI:", dbError); // Log spécifique
1026
+ throw dbError; // Relancer pour être attrapé par le catch principal
1027
+ }
1028
+
1029
+ // ---> TODO: Vérifier droits sur targetModel
1030
+
1031
+ // 2. Construire le pipeline
1032
+ const pipeline = [];
1033
+ let matchFilter = { _model: kpiDef.targetModel, $or: [{_user: req.me._user}, {_user: req.me.username}] };
1034
+ let totalCount = null;
1035
+
1036
+ // Calcul du totalCount
1037
+ if (kpiDef.showTotal || kpiDef.showPercentTotal) {
1038
+ try {
1039
+ const parsedTotalMatch = kpiDef.totalMatchFormula || {}; // Assurer un objet vide si null/undefined
1040
+ const totalPipeline = [
1041
+ { "$match": { ...matchFilter, ...parsedTotalMatch } },
1042
+ { "$count": 'count' }
1043
+ ];
1044
+ const result = await datasCollection.aggregate(totalPipeline).toArray();
1045
+ totalCount = result.length > 0 ? result[0]['count'] : 0;
1046
+ } catch (totalError) {
1047
+ if (totalError instanceof SyntaxError) {
1048
+ console.error(`>>> ERREUR JSON.parse (totalMatchFormula) pour KPI ${id}:`, kpiDef.totalMatchFormula, totalError);
1049
+ } else {
1050
+ console.error(`>>> ERREUR Aggregate (totalCount) pour KPI ${id}:`, totalError);
1051
+ }
1052
+ throw totalError; // Relancer
1053
+ }
1054
+ }
1055
+
1056
+ // Filtre principal
1057
+ if (kpiDef.matchFormula) {
1058
+ try {
1059
+ const parsedMatch = kpiDef.matchFormula || {}; // Assurer un objet vide
1060
+ matchFilter = { ...matchFilter, ...parsedMatch };
1061
+ } catch (matchError) {
1062
+ console.error(`>>> ERREUR JSON.parse (matchFormula) pour KPI ${id}:`, kpiDef.matchFormula, matchError);
1063
+ throw matchError; // Relancer
1064
+ }
1065
+ }
1066
+ pipeline.push({ $match: matchFilter });
1067
+
1068
+ // 3. Stage d'agrégation principal
1069
+ let resultValue = 0;
1070
+ const resultFieldName = 'calculatedValue';
1071
+
1072
+ try { // <--- Try spécifique pour l'agrégation principale
1073
+ if (kpiDef.aggregationType === 'count') {
1074
+ pipeline.push({ $count: resultFieldName });
1075
+ const result = await datasCollection.aggregate(pipeline).toArray();
1076
+ resultValue = result.length > 0 ? result[0][resultFieldName] : 0;
1077
+
1078
+ } else if (['sum', 'avg', 'min', 'max'].includes(kpiDef.aggregationType)) {
1079
+ if (!kpiDef.aggregationField) {
1080
+ return res.status(400).json({ success: false, error: `aggregationField is required for type ${kpiDef.aggregationType}` });
1081
+ }
1082
+ const mongoOperator = `$${kpiDef.aggregationType}`;
1083
+ const targetField = `$${kpiDef.aggregationField}`;
1084
+
1085
+ pipeline.push({
1086
+ $group: {
1087
+ _id: null,
1088
+ [resultFieldName]: { [mongoOperator]: targetField }
1089
+ }
1090
+ });
1091
+ const result = await datasCollection.aggregate(pipeline).toArray();
1092
+ resultValue = result.length > 0 ? result[0][resultFieldName] : 0;
1093
+ if (kpiDef.aggregationType === 'avg' && result.length === 0) {
1094
+ resultValue = 0;
1095
+ }
1096
+ } else {
1097
+ return res.status(400).json({ success: false, error: `Unsupported aggregationType: ${kpiDef.aggregationType}` });
1098
+ }
1099
+ } catch (aggError) {
1100
+ console.error(`>>> ERREUR Aggregate (principal) pour KPI ${id}:`, aggError); // Log spécifique
1101
+ throw aggError; // Relancer
1102
+ }
1103
+
1104
+ // 4. Retourner la valeur
1105
+ res.json({ success: true, value: resultValue, totalCount: totalCount });
1106
+
1107
+ } catch (error) { // <--- CATCH PRINCIPAL
1108
+
1109
+ // Tentative de log via le logger standard (qui peut encore échouer si l'erreur est vraiment étrange)
1110
+ try {
1111
+ logger.error(`KPI Calculation Error for ID ${id} (Caught in main handler):`, error);
1112
+ } catch (loggerError) {
1113
+ console.error(">>> ERREUR DANS logger.error LUI-MEME:", loggerError);
1114
+ }
1115
+
1116
+ // Réponse d'erreur générique
1117
+ const errorMsg = kpiDef ? `Internal server error during KPI calculation for '${kpiDef.name}'` : 'Internal server error during KPI calculation';
1118
+ res.status(500).json({ success: false, error: errorMsg });
1119
+ }
1120
+ });
1121
+ /**
1122
+ * Route: POST /api/charts/aggregate
1123
+ * Description: Aggregates data for chart generation based on provided configuration.
1124
+ * Body: {
1125
+ * title: string, // Chart title (not directly used in aggregation)
1126
+ * model: string, // The name of the model to query
1127
+ * type: string, // Chart type ('bar', 'line', 'pie', 'doughnut')
1128
+ * xAxis?: string, // Field for X-axis (for bar/line charts)
1129
+ * yAxis?: string, // Field for Y-axis (numeric, for bar/line charts)
1130
+ * groupBy?: string, // Field for grouping (enum, for pie/doughnut charts)
1131
+ * aggregationType?: string // Aggregation type for Y-axis ('sum', 'avg', 'count', etc.)
1132
+ * }
1133
+ * Returns: Array of aggregated data points (e.g., [{ label: '...', value: ... }]) or an error.
1134
+ */
1135
+
1136
+ engine.post('/api/charts/aggregate', [throttle, middlewareAuthenticator, userInitiator, ...userMiddlewares, setTimeoutMiddleware(15000)], async (req, res) => {
1137
+ // --- Récupérer groupByLabelField ---
1138
+ const { model: modelName, type, xAxis, yAxis, groupBy, aggregationType, groupByLabelField, filter: chartFilter } = req.fields;
1139
+
1140
+ // --- Validation (inchangée) ---
1141
+ const isGroupingChart = ['pie', 'doughnut'].includes(type);
1142
+ if (!modelName || !type) {
1143
+ return res.status(400).json({ error: '`model` and `type` are required.' });
1144
+ }
1145
+ if (isGroupingChart && !groupBy) {
1146
+ return res.status(400).json({ error: '`groupBy` is required for pie/doughnut charts.' });
1147
+ }
1148
+ if (!isGroupingChart && !xAxis) {
1149
+ return res.status(400).json({ error: '`xAxis` is required for bar/line charts.' });
1150
+ }
1151
+ // Default aggregationType if not provided
1152
+ const effectiveAggregationType = aggregationType || 'count';
1153
+
1154
+ // --- MODIF: 'value' requiert aussi yAxis ---
1155
+ // Define requiresYAxis based on the effective aggregation type
1156
+ const requiresYAxis = effectiveAggregationType && !['count'].includes(effectiveAggregationType); // 'value' requires yAxis
1157
+
1158
+ // Validate yAxis presence if aggregation requires it
1159
+ if (requiresYAxis && !yAxis) {
1160
+ return res.status(400).json({ error: `\`yAxis\` is required for aggregation type '${effectiveAggregationType}'.` });
1161
+ }
1162
+ // --- FIN MODIF ---
1163
+
1164
+
1165
+ try {
1166
+ // --- Check Model Existence (inchangé) ---
1167
+ const modelDefinition = await modelsCollection.findOne({
1168
+ name: modelName,
1169
+ $or: [{ _user: { $exists: false } }, { _user: req.me._user || req.me.username }]
1170
+ });
1171
+ if (!modelDefinition) {
1172
+ return res.status(404).json({ error: `Model '${modelName}' not found or not accessible.` });
1173
+ }
1174
+
1175
+ // --- Trouver la définition du champ groupBy ET vérifier s'il est multiple (inchangé) ---
1176
+ const groupByFieldDefinition = modelDefinition.fields.find(f => f.name === groupBy);
1177
+ const isRelationGroupBy = isGroupingChart && groupByFieldDefinition?.type === 'relation';
1178
+ const isMultipleRelation = isRelationGroupBy && groupByFieldDefinition?.multiple === true;
1179
+
1180
+ // --- Build Aggregation Pipeline ---
1181
+ const collection = await getCollectionForUser(req.me);
1182
+
1183
+ // --- MODIFICATION ICI pour inclure chartFilter ---
1184
+ let initialMatchStage = { $and : [{
1185
+ _model: modelName},{
1186
+ _user: req.me.username
1187
+ }]};
1188
+ if (chartFilter && typeof chartFilter === 'object' && Object.keys(chartFilter).length > 0) {
1189
+ // Fusionner le filtre fourni avec le filtre de base
1190
+ // Assurez-vous que chartFilter est bien "sanitized" ou construit de manière sécurisée
1191
+ // pour éviter les injections NoSQL si une partie est générée dynamiquement.
1192
+ // La fonction cleanFilter que vous avez pourrait être utile ici si le filtre vient d'une UI complexe.
1193
+ initialMatchStage = { $and: [...initialMatchStage['$and'], { $expr: chartFilter }] };
1194
+ logger.debug(`[charts/aggregate] Applying custom filter:`, util.inspect(initialMatchStage, false, 8, true));
1195
+ }
1196
+
1197
+
1198
+ const pipeline = [{ $match: initialMatchStage }];
1199
+
1200
+ // --- Validation des opérateurs d'agrégation ---
1201
+ const validAggregations = {
1202
+ // count is handled separately by $sum: 1
1203
+ sum: '$sum',
1204
+ avg: '$avg',
1205
+ min: '$min',
1206
+ max: '$max'
1207
+ // median needs special handling later
1208
+ // *** AJOUT: 'value' sera géré par $first (ou $last) ***
1209
+ };
1210
+ const mongoAggregationOperator = validAggregations[effectiveAggregationType];
1211
+
1212
+ // *** MODIF: Mettre à jour la vérification pour accepter 'value' ***
1213
+ if (effectiveAggregationType && !mongoAggregationOperator && !['median', 'value', 'count'].includes(effectiveAggregationType)) {
1214
+ return res.status(400).json({ error: `Unsupported aggregationType: ${effectiveAggregationType}` });
1215
+ }
1216
+ // La validation de yAxis est déjà faite plus haut avec requiresYAxis
1217
+
1218
+
1219
+ // --- Construction du stage $group ---
1220
+ let groupStage = { _id: null, value: {} };
1221
+ let idFieldForGrouping = null;
1222
+
1223
+ if (isGroupingChart) {
1224
+ // --- Logique pour Pie/Doughnut (Relation ou autre) ---
1225
+ if (isRelationGroupBy) {
1226
+ // ... (logique existante pour gérer les relations simples et multiples, inchangée) ...
1227
+ // --- LOGIQUE EXISTANTE POUR RELATION SIMPLE/MULTIPLE ---
1228
+ const relatedModelName = groupByFieldDefinition.relation;
1229
+ if (!relatedModelName) { return res.status(400).json({ error: `Relation model not defined for groupBy field '${groupBy}'.` }); }
1230
+ const relatedModelDef = await modelsCollection.findOne({ name: relatedModelName, $or: [{ _user: { $exists: false } }, { _user: req.me._user || req.me.username }] });
1231
+ if (!relatedModelDef && groupByLabelField) { logger.warn(`[charts/aggregate] Related model '${relatedModelName}' not found, but proceeding with specified groupByLabelField '${groupByLabelField}'.`); }
1232
+ else if (!relatedModelDef && !groupByLabelField) { return res.status(400).json({ error: `Related model '${relatedModelName}' not found and no groupByLabelField specified.` }); }
1233
+ let labelField = groupByLabelField;
1234
+ if (!labelField && relatedModelDef) {
1235
+ const defaultLabelField = relatedModelDef.fields.find(f => f.asMain && ['string', 'string_t', 'enum'].includes(f.type))?.name || relatedModelDef.fields.find(f => f.name === 'name' && ['string', 'string_t', 'enum'].includes(f.type))?.name || relatedModelDef.fields.find(f => f.name === 'title' && ['string', 'string_t', 'enum'].includes(f.type))?.name;
1236
+ if (defaultLabelField) { labelField = defaultLabelField; logger.debug(`[charts/aggregate] Using default label field '${labelField}' for relation '${relatedModelName}'.`); }
1237
+ }
1238
+ if (!labelField) labelField = '_id';
1239
+
1240
+ if (isMultipleRelation) {
1241
+ pipeline.push({ $unwind: { path: `$${groupBy}`, preserveNullAndEmptyArrays: true } });
1242
+ pipeline.push({ $addFields: { [`${groupBy}_oid`]: { $cond: { if: { $eq: [{ $type: `$${groupBy}` }, "string"] }, then: { $toObjectId: `$${groupBy}` }, else: `$${groupBy}` } } } });
1243
+ pipeline.push({ $lookup: { from: collection.collectionName, localField: `${groupBy}_oid`, foreignField: '_id', as: 'relatedDoc' } });
1244
+ pipeline.push({ $unwind: { path: '$relatedDoc', preserveNullAndEmptyArrays: true } });
1245
+ idFieldForGrouping = { $ifNull: [`$relatedDoc.${labelField}`, null] };
1246
+ } else {
1247
+ pipeline.push({ $addFields: { [`${groupBy}_oid`]: { $cond: { if: { $eq: [{ $type: `$${groupBy}` }, "string"] }, then: { $toObjectId: `$${groupBy}` }, else: `$${groupBy}` } } } });
1248
+ pipeline.push({ $lookup: { from: collection.collectionName, localField: `${groupBy}_oid`, foreignField: '_id', as: 'relatedDoc' } });
1249
+ pipeline.push({ $unwind: { path: '$relatedDoc', preserveNullAndEmptyArrays: true } });
1250
+ idFieldForGrouping = { $ifNull: [`$relatedDoc.${labelField}`, null] };
1251
+ }
1252
+ // --- FIN LOGIQUE RELATION ---
1253
+ } else {
1254
+ // --- Logique pour Enum/String/Date etc. (inchangée) ---
1255
+ idFieldForGrouping = `$${groupBy}`;
1256
+ }
1257
+
1258
+ // --- Définir l'opération d'agrégation pour la valeur ---
1259
+ if (effectiveAggregationType === 'count') {
1260
+ groupStage.value = { '$sum': 1 };
1261
+ }
1262
+ // *** AJOUT: Gérer le cas 'value' ***
1263
+ else if (effectiveAggregationType === 'value') {
1264
+ // Utiliser $first pour prendre la valeur du champ yAxis du premier document du groupe.
1265
+ // Note: Si l'ordre est important, un $sort peut être nécessaire AVANT le $group.
1266
+ groupStage.value = { $first: `$${yAxis}` };
1267
+ }
1268
+ // *** FIN AJOUT ***
1269
+ else if (requiresYAxis) { // Pour sum, avg, min, max
1270
+ // Convertir yAxis en nombre avant l'agrégation
1271
+ pipeline.push({
1272
+ $addFields: { numericYValue: { $cond: { if: { $ne: [`$${yAxis}`, null] }, then: { $toDouble: `$${yAxis}` }, else: null } } }
1273
+ });
1274
+ groupStage.value = { [mongoAggregationOperator]: '$numericYValue' };
1275
+ }
1276
+ // Note: 'median' n'est pas géré pour les graphiques de groupement dans ce code
1277
+
1278
+ } else {
1279
+ // --- Logique pour Bar/Line ---
1280
+ idFieldForGrouping = `$${xAxis}`;
1281
+
1282
+ if (effectiveAggregationType === 'count') {
1283
+ groupStage.value = { '$sum': 1 };
1284
+ }
1285
+ else if (effectiveAggregationType === 'value') {
1286
+ // Utiliser $first pour prendre la valeur du champ yAxis du premier document du groupe.
1287
+ groupStage.value = { $first: `$${yAxis}` };
1288
+ // Note: Si l'ordre est important (ex: dernier point d'une série temporelle),
1289
+ // un $sort sur le champ date/heure AVANT $group et utiliser $last ici serait nécessaire.
1290
+ }
1291
+ else if (requiresYAxis) { // Pour sum, avg, min, max, median
1292
+ pipeline.push({
1293
+ $addFields: { numericYValue: { $cond: { if: { $ne: [`$${yAxis}`, null] }, then: { $toDouble: `$${yAxis}` }, else: null } } }
1294
+ });
1295
+
1296
+ if (effectiveAggregationType === 'median') {
1297
+ groupStage.valuesForMedian = { $push: '$numericYValue' }; // Collecter pour median
1298
+ delete groupStage.value; // Supprimer le placeholder 'value'
1299
+ } else {
1300
+ groupStage.value = { [mongoAggregationOperator]: '$numericYValue' };
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ // --- Assembler et exécuter le pipeline ---
1306
+ groupStage._id = idFieldForGrouping; // Assigner le champ de groupement
1307
+ pipeline.push({ $group: groupStage });
1308
+
1309
+ // --- Handle Median Calculation (if applicable, inchangé) ---
1310
+ if (!isGroupingChart && effectiveAggregationType === 'median') {
1311
+ // ... (logique existante pour calculer la médiane après le $group, inchangée) ...
1312
+ pipeline.push(
1313
+ { $project: { _id: 1, sortedValues: { $sortArray: { input: "$valuesForMedian", sortBy: 1 } } } },
1314
+ { $addFields: { count: { $size: "$sortedValues" }, midIndex: { $floor: { $divide: [{ $subtract: [{ $size: "$sortedValues" }, 1] }, 2] } } } },
1315
+ { $addFields: { value: { $cond: { if: { $eq: ["$count", 0] }, then: 0, else: { $cond: { if: { $eq: [{ $mod: ["$count", 2] }, 1] }, then: { $arrayElemAt: ["$sortedValues", "$midIndex"] }, else: { $avg: [ { $arrayElemAt: ["$sortedValues", "$midIndex"] }, { $arrayElemAt: ["$sortedValues", { $add: ["$midIndex", 1] }] } ] } } } } } } }
1316
+ );
1317
+ }
1318
+
1319
+
1320
+ // Projection finale pour formater la sortie (inchangée)
1321
+ pipeline.push({
1322
+ $project: {
1323
+ _id: 0,
1324
+ label: { $ifNull: ['$_id', i18n.t('charts.labelNull', '(Non défini)')] },
1325
+ value: '$value' // Le champ 'value' contient maintenant soit l'agrégat, soit la valeur brute ($first)
1326
+ }
1327
+ });
1328
+
1329
+ // Tri final par label (inchangé)
1330
+ const sort = typeof(req.query.sort) === 'string' ? req.query.sort : { _id: -1 };
1331
+ pipeline.push({ $sort: sort });
1332
+ pipeline.push({ $limit: 500 });
1333
+
1334
+ console.log("[charts/aggregate] Pipeline:", util.inspect(pipeline, false, 8, true)); // Garder ce log pour vérifier
1335
+
1336
+ // --- Execute Aggregation ---
1337
+ const results = await collection.aggregate(pipeline).toArray();
1338
+ const finalResults = results;
1339
+
1340
+ // --- Send Response ---
1341
+ res.json(finalResults);
1342
+
1343
+ } catch (error) {
1344
+ // ... (gestion des erreurs existante, inchangée) ...
1345
+ console.error("Error during chart aggregation:", error);
1346
+ logger.error("Error during chart aggregation:", error);
1347
+ if (error.message.includes('relation model not defined')) { return res.status(400).json({ error: error.message }); }
1348
+ if (error.codeName === 'TypeMismatch' || error.message.includes('$toDouble') || error.message.includes('must be numeric')) {
1349
+ // *** MODIF: S'assurer que yAxis est bien le champ problématique pour 'value' ***
1350
+ const problematicField = requiresYAxis ? yAxis : (isGroupingChart ? groupBy : xAxis);
1351
+ return res.status(400).json({ error: `Field type mismatch during aggregation. Ensure '${problematicField}' contains compatible data for the operation. Details: ${error.message}` });
1352
+ }
1353
+ res.status(500).json({ error: 'An error occurred during data aggregation.' });
1354
+ }
1355
+ });
1356
+
1357
+
1358
+ engine.post('/api/data/removeFromPack', [throttle, middlewareAuthenticator, userInitiator, ...userMiddlewares], async (req, res) => {
1359
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_CREATE_PACK"], req.me)){
1360
+ return res.status(403).json({success: false, error: i18n.t('api.permission.createPack')})
1361
+ }
1362
+ const { itemIds } = req.fields;
1363
+ if (!Array.isArray(itemIds) || itemIds.length === 0 || !itemIds.every(isObjectId)) { // Assurez-vous que isObjectId est importé/défini
1364
+ return res.status(400).json({ success: false, error: 'itemIds must be a non-empty array of valid ObjectIds.' });
1365
+ }
1366
+ const objectIds = itemIds.map(id => new ObjectId(id));
1367
+ const collection = await getCollectionForUser(req.me); // Obtenir la collection de l'utilisateur
1368
+
1369
+ const results = await collection.find({
1370
+ _id: { $in: objectIds },
1371
+ _user: req.me._user || req.me.username
1372
+ }).toArray();
1373
+ const result = await collection.deleteMany(
1374
+ {
1375
+ _hash: { $in: results.map(r => r._hash) },
1376
+ _pack: { $exists: true },
1377
+ _user: req.me._user || req.me.username
1378
+ }
1379
+ );
1380
+
1381
+ if (result.deletedCount > 0) {
1382
+ res.json({ success: true, modifiedCount: result.deletedCount });
1383
+ } else {
1384
+ // Gérer le cas où aucun document n'a été modifié (peut-être qu'ils n'avaient pas de _pack)
1385
+ res.json({ success: true, modifiedCount: 0, message: "No items found or modified." });
1386
+ }
1387
+ })
1388
+
1389
+ // GET /api/packs - Liste les packs pour la galerie depuis la collection "packs"
1390
+ engine.get('/api/packs', [throttle, middlewareAuthenticator, userInitiator], async (req, res) => {
1391
+ try {
1392
+ // On accède directement à la collection 'packs'
1393
+ const packsCollection = getCollection('packs');
1394
+ const { sortBy = '_updatedAt', order = -1 } = req.query; // Ajout du tri pour la galerie
1395
+
1396
+ const sortOptions = {};
1397
+ // Validation simple du champ de tri pour la sécurité
1398
+ if (['name', 'stars', '_createdAt', '_updatedAt'].includes(sortBy)) {
1399
+ sortOptions[sortBy] = parseInt(order, 10) === 1 ? 1 : -1;
1400
+ } else {
1401
+ sortOptions['_updatedAt'] = -1; // Tri par défaut
1402
+ }
1403
+
1404
+ // On ne renvoie pas le champ 'datas' pour alléger la réponse de la liste
1405
+ const packs = await packsCollection.find({}, {
1406
+ projection: { datas: 0 }
1407
+ }).sort(sortOptions).toArray();
1408
+
1409
+ res.json(packs);
1410
+ } catch (error) {
1411
+ logger.error('[GET /api/packs] Error fetching packs:', error);
1412
+ res.status(500).json({ success: false, error: 'Failed to fetch packs.' });
1413
+ }
1414
+ });
1415
+
1416
+ // GET /api/packs/:id - Récupère les détails complets d'un pack
1417
+ engine.get('/api/packs/:id', [throttle, middlewareAuthenticator, userInitiator], async (req, res) => {
1418
+ const { id } = req.params;
1419
+ if (!isObjectId(id)) {
1420
+ return res.status(400).json({ success: false, error: 'Invalid pack ID format.' });
1421
+ }
1422
+
1423
+ try {
1424
+ const packsCollection = getCollection('packs');
1425
+ const pack = await packsCollection.findOne({ _id: new ObjectId(id) });
1426
+
1427
+ if (!pack) {
1428
+ return res.status(404).json({ success: false, error: 'Pack not found.' });
1429
+ }
1430
+
1431
+ // On retourne le pack complet, incluant les données pour la vue de détail
1432
+ res.json(pack);
1433
+ } catch (error) {
1434
+ logger.error(`[GET /api/packs/${id}] Error fetching pack details:`, error);
1435
+ res.status(500).json({ success: false, error: 'Failed to fetch pack details.' });
1436
+ }
1437
+ });
1438
+
1439
+ // --- Endpoint DELETE pour supprimer un pack ---
1440
+ engine.delete('/api/packs/:packName', [throttle, middlewareAuthenticator, userInitiator], async (req, res) => {
1441
+ const { packName } = req.params;
1442
+ const { model } = req.query; // Récupmodèle depuis les query params
1443
+ const user = req.me;
1444
+
1445
+ // --- Validation ---
1446
+ if (!packName) {
1447
+ return res.status(400).json({ success: false, error: 'Pack name is required in URL path.' });
1448
+ }
1449
+ if (!model) {
1450
+ return res.status(400).json({ success: false, error: 'Model query parameter is required.' });
1451
+ }
1452
+
1453
+ try {
1454
+ // --- Vérification des permissions (Adaptez selon vos règles) ---
1455
+ // Exemple : Seul l'admin ou le propriétaire peut supprimer
1456
+ const isAdmin = await hasPermission(["API_ADMIN", "API_DELETE_PACK"], user); // Assurez-vous que hasPermission est bien défini et fonctionnel
1457
+ const isOwner = true; // Pour cet exemple, on suppose que l'utilisateur est propriétaire. Adaptez la logique si nécessaire.
1458
+
1459
+ if (user.username !== 'demo' && !isAdmin && !isOwner) { // Adaptez cette condition
1460
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.deletePack', 'Permission denied to delete this pack.') });
1461
+ }
1462
+
1463
+ // --- Logique de suppression ---
1464
+ const collection = await getCollectionForUser(user); // Obtenir la collection spécifique à l'utilisateur
1465
+
1466
+ const deleteFilter = {
1467
+ _pack: packName,
1468
+ _model: model,
1469
+ _user: user.username // Assurer que l'utilisateur ne supprime que ses propres données de pack
1470
+ };
1471
+
1472
+ const result = await collection.deleteMany(deleteFilter);
1473
+
1474
+ if (result.deletedCount > 0) {
1475
+ logger.info(`User ${user.username} deleted ${result.deletedCount} items from pack '${packName}' for model '${model}'.`);
1476
+ res.json({ success: true, deletedCount: result.deletedCount });
1477
+ } else {
1478
+ logger.warn(`User ${user.username} tried to delete pack '${packName}' for model '${model}', but no matching items found or deletion failed.`);
1479
+ // Renvoyer succès même si rien n'a été trouvé peut être acceptable
1480
+ res.json({ success: true, deletedCount: 0, message: 'No items found for this pack and model belonging to the user.' });
1481
+ // Ou renvoyer une erreur 404 si le pack n'existe pas du tout pour cet utilisateur/modèle
1482
+ // return res.status(404).json({ success: false, error: 'Pack not found for this user and model.' });
1483
+ }
1484
+
1485
+ } catch (error) {
1486
+ logger.error(`Error deleting pack '${packName}' for model '${model}' by user ${user.username}:`, error);
1487
+ res.status(500).json({ success: false, error: 'An internal server error occurred.' });
1488
+ }
1489
+ });
1490
+
1491
+ engine.post('/api/data/addToPack', [throttle, middlewareAuthenticator, userInitiator,...userMiddlewares], async (req, res) => {
1492
+ const { packName, itemIds } = req.fields;
1493
+ const user = req.me;
1494
+
1495
+ if( !(isDemoUser(req.me) && Config.Get("useDemoAccounts")) && isLocalUser(req.me) && !await hasPermission(["API_ADMIN", "API_CREATE_PACK"], req.me)){
1496
+ return res.status(403).json({success: false, error: i18n.t('api.permission.createPack')})
1497
+ }
1498
+ // --- Validation ---
1499
+ if (!packName || typeof packName !== 'string' || packName.trim() === '') {
1500
+ return res.status(400).json({ success: false, error: 'Pack name is required and must be a non-empty string.' });
1501
+ }
1502
+ if (!Array.isArray(itemIds) || itemIds.length === 0 || !itemIds.every(isObjectId)) { // Assurez-vous que isObjectId est importé/défini
1503
+ return res.status(400).json({ success: false, error: 'itemIds must be a non-empty array of valid ObjectIds.' });
1504
+ }
1505
+
1506
+ // --- Logique Métier ---
1507
+ try {
1508
+ const collection = await getCollectionForUser(user); // Récupère la collection de l'utilisateur
1509
+ const objectIds = itemIds.map(id => new ObjectId(id)); // Convertit les strings en ObjectIds
1510
+
1511
+ // Met à jour le champ _pack pour les documents sélectionnés appartenant à l'utilisateur
1512
+ const copyData = await collection.find({
1513
+ _id: { $in: objectIds },
1514
+ _user: user.username
1515
+ }).toArray();
1516
+
1517
+
1518
+ const result = await collection.insertMany(copyData.map(c => ({...c, _id: undefined, _pack: packName})));
1519
+ res.json({ success: true, modifiedCount: result.insertedCount, matchedCount: result.insertedCount });
1520
+
1521
+ } catch (error) {
1522
+ logger.error(`Error adding items to pack for user ${user.username}, pack '${packName}':`, error);
1523
+ res.status(500).json({ success: false, error: 'An internal server error occurred.' });
1524
+ }
1525
+ });
1526
+
1527
+
1528
+ // --- NOUVEL ENDPOINT D'INSTALLATION DE PACK ---
1529
+ engine.post('/api/packs/:id/install', [throttle, middlewareAuthenticator, userInitiator, setTimeoutMiddleware(60000)], async (req, res) => {
1530
+ const { id } = req.params;
1531
+ const user = req.me;
1532
+ const lang = req.query.lang;
1533
+
1534
+ if (!isObjectId(id)) {
1535
+ return res.status(400).json({ success: false, error: 'Invalid pack ID format.' });
1536
+ }
1537
+
1538
+ try {
1539
+ // Vérification des permissions
1540
+ if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_INSTALL_PACK"], user)) {
1541
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.installPack') });
1542
+ }
1543
+
1544
+ const result = await installPack(id, user, lang);
1545
+
1546
+ if (result.success) {
1547
+ res.status(200).json({ success: true, message: `Pack installed successfully.`, summary: result.summary });
1548
+ } else if (!result.success && !result.modifiedCount) {
1549
+ res.status(200).json({ success: true, message: `No data to insert.`, summary: result.summary });
1550
+ } else {
1551
+ res.status(400).json({ success: false, error: 'Pack installation had errors.', errors: result.errors, summary: result.summary });
1552
+ }
1553
+
1554
+ } catch (error) {
1555
+ logger.error(`[POST /api/packs/${id}/install] Critical error:`, error);
1556
+ res.status(500).json({ success: false, error: error.message || 'An internal server error occurred.' });
1557
+ }
1558
+ });
1559
+ /*
1560
+ engine.post('/api/packs/install', [throttle, middlewareAuthenticator, userInitiator, ...userMiddlewares], async (req, res) => {
1561
+
1562
+ const { pack } = req.fields;
1563
+ const initialModelName = req.query.model; // The starting model
1564
+ const user = req.me;
1565
+ const collection = await getCollectionForUser(user);
1566
+
1567
+ try {
1568
+ // --- Permission Check ---
1569
+ if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_INSTALL_PACK"], user)) {
1570
+ return res.status(403).json({ success: false, error: i18n.t('api.permission.installPack') });
1571
+ }
1572
+
1573
+ // --- Input Validation ---
1574
+ if (!pack) { return res.status(400).json({ success: false, error: 'Pack name is required.' }); }
1575
+ if (!initialModelName) { return res.status(400).json({ success: false, error: 'Model name query parameter is required.' }); }
1576
+
1577
+ // --- Initialize Shared State ---
1578
+ const idMap = {}; // Shared map: oldObjectIdString -> newObjectId
1579
+ const processedModels = new Set(); // Shared set to track processed models and detect cycles
1580
+
1581
+ logger.info(`--- Starting Recursive Pack Installation --- User: ${user.username}, Pack: '${pack}', Initial Model: '${initialModelName}'`);
1582
+
1583
+
1584
+ // --- Start Recursive Installation ---
1585
+ await installPack(logger, pack, initialModelName, user, collection);
1586
+
1587
+ logger.info(`--- Recursive Pack Installation Completed --- User: ${user.username}, Pack: '${pack}'. Final ID Map size: ${Object.keys(idMap).length}. Processed models: [${Array.from(processedModels).join(', ')}]`);
1588
+
1589
+ res.status(200).json({ success: true, message: `Pack '${pack}' installation process completed starting from model '${initialModelName}'.` });
1590
+
1591
+ } catch (error) {
1592
+ logger.error(`Critical error during pack installation for pack '${pack}', model '${initialModelName}':`, error);
1593
+ res.status(500).json({ success: false, error: error.message || 'An internal server error occurred during pack installation.' });
1594
+ }
1595
+ });
1596
+ */
1597
+
1598
+ engine.get('/resources/:guid', [middlewareAuthenticator, userInitiator, throttle], async (req, res) => {
1599
+ try {
1600
+ const { guid } = req.params;
1601
+ const user = req.me;
1602
+ const resourceInfo = await getResource(guid, user); // Passez l'objet utilisateur
1603
+ res.setHeader('Content-Type', resourceInfo.mimeType);
1604
+
1605
+ if (resourceInfo.storage === 's3') {
1606
+ const s3Config = await getUserS3Config(user);
1607
+ const s3Stream = getS3Stream(s3Config, resourceInfo.s3Key);
1608
+
1609
+ s3Stream.on('error', (s3Error) => {
1610
+ logger.error(`S3 stream error for resource ${guid}:`, s3Error);
1611
+ res.status(404).json({ error: 'Resource file not found in storage.' });
1612
+ });
1613
+
1614
+ s3Stream.pipe(res);
1615
+ } else { // Stockage 'local'
1616
+ const fileStream = fs.createReadStream(resourceInfo.filepath);
1617
+ fileStream.on('error', (streamError) => {
1618
+ console.error(`Stream error for resource ${guid}:`, streamError); // ou logger.error
1619
+ res.status(404).json({error: 'Resource file not found on server.'});
1620
+ });
1621
+ fileStream.pipe(res);
1622
+ }
1623
+
1624
+ } catch (error) {
1625
+ console.error(`Error serving resource ${req.params.guid}:`, error); // ou logger.error
1626
+ if (error.message.includes("n'êtes pas autorisé")) {
1627
+ res.status(403).json({ error: 'Forbidden: You are not authorized to access this file.' });
1628
+ } else if (error.message.includes("non trouvé")) { // "Fichier non trouvé" ou "GUID du fichier n'est pas valide"
1629
+ res.status(404).json({ error: 'Not Found: The requested resource does not exist or the GUID is invalid.' });
1630
+ } else {
1631
+ res.status(500).json({ error: 'Internal server error while serving the resource.' });
1632
+ }
1633
+ }
1634
+ });
1635
+
1636
+ logger.info("Data module loaded");
1637
+ }