data-primals-engine 1.7.0 → 1.7.2

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