data-primals-engine 1.4.0 → 1.4.2

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