data-primals-engine 1.7.1 → 1.7.3

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