data-primals-engine 1.4.1 → 1.4.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 (49) hide show
  1. package/README.md +37 -22
  2. package/client/package-lock.json +33 -0
  3. package/client/package.json +1 -0
  4. package/client/src/App.scss +12 -4
  5. package/client/src/AssistantChat.jsx +48 -5
  6. package/client/src/AssistantChat.scss +0 -1
  7. package/client/src/ChartConfigModal.jsx +34 -9
  8. package/client/src/ConditionBuilder.jsx +1 -1
  9. package/client/src/ConditionBuilder2.jsx +2 -1
  10. package/client/src/Dashboard.jsx +396 -349
  11. package/client/src/DashboardChart.jsx +91 -12
  12. package/client/src/DataEditor.jsx +376 -368
  13. package/client/src/DataLayout.jsx +3 -2
  14. package/client/src/DataTable.jsx +60 -31
  15. package/client/src/Field.jsx +1788 -1782
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/ModelCreatorField.jsx +1 -0
  18. package/client/src/RelationField.jsx +2 -2
  19. package/client/src/constants.js +2 -2
  20. package/client/src/contexts/UIContext.jsx +5 -2
  21. package/client/src/filter.js +0 -155
  22. package/client/src/translations.js +16828 -16750
  23. package/package.json +10 -2
  24. package/src/config.js +2 -2
  25. package/src/constants.js +262 -4
  26. package/src/core.js +21 -3
  27. package/src/defaultModels.js +12 -12
  28. package/src/engine.js +7 -1
  29. package/src/events.js +4 -3
  30. package/src/filter.js +272 -260
  31. package/src/i18n.js +187 -177
  32. package/src/middlewares/middleware-mongodb.js +3 -1
  33. package/src/modules/assistant/assistant.js +131 -42
  34. package/src/modules/bucket.js +3 -2
  35. package/src/modules/data/data.backup.js +374 -0
  36. package/src/modules/data/data.core.js +2 -1
  37. package/src/modules/data/data.history.js +11 -8
  38. package/src/modules/data/data.js +190 -4551
  39. package/src/modules/data/data.operations.js +3000 -0
  40. package/src/modules/data/data.relations.js +687 -0
  41. package/src/modules/data/data.routes.js +132 -38
  42. package/src/modules/data/data.scheduling.js +274 -0
  43. package/src/modules/data/data.validation.js +248 -0
  44. package/src/modules/data/index.js +29 -1
  45. package/src/modules/user.js +2 -1
  46. package/src/modules/workflow.js +3 -2
  47. package/src/services/stripe.js +3 -2
  48. package/swagger-en.yml +133 -0
  49. package/test/model.integration.test.js +377 -221
@@ -58,7 +58,7 @@ async function searchModels(query, user) {
58
58
  $or: [{ _user: user.username }, { _user: { $exists: false } }],
59
59
  $and: [{ $or: [{ name: { $regex: searchRegex } }, { description: { $regex: searchRegex } }] }]
60
60
  }, {
61
- projection: { name: 1, description: 1, _id: 0 }
61
+ projection: { name: 1, description: 1, fields: 1, _id: 0 }
62
62
  }).limit(10).toArray();
63
63
  }
64
64
 
@@ -74,39 +74,49 @@ const createSystemPrompt = (modelDefs, lang) => {
74
74
  Tu es "Prior", un assistant expert en analyse de données pour le moteur data-primals-engine..
75
75
  Ta mission est d'aider l'utilisateur en répondant à ses questions sur ses données.
76
76
 
77
+ REGLE FONDATRICE : suis les règles et ne dévie pas du chemin.
78
+ STYLE UTILISE : apporte l'information au plus rapide, sans détours, ni sollicitation à l'utilisateur, ou à des tiers.
77
79
 
78
80
  FORMAT DE RÉPONSE OBLIGATOIRE :
79
81
  Un SEUL objet JSON valide contenant exactement 2 champs :
80
82
  1. "action" (string)
81
83
  2. "params" (object)
82
- 2.1 : query (string) : Les mots clés de la recherche
83
- 2.2 : filter (object) : Les filtres de la recherche
84
- 2.3 : limit (number) : Le nombre de résultats à afficher
85
- 2.4 : sort (string) : La clé de tri de la recherche
86
- 2.6 : model (string) : Le nom du modèle à utiliser
87
- 2.7 : data (object) : Les données à ajouter à la base de données
88
-
89
- Tu as accès aux outils suivants.
90
-
91
- NOUVEL OUTIL PRIORITAIRE:
92
- 1. **search_models**: À utiliser EN PREMIER SI TU NE SAIS PAS QUEL MODÈLE UTILISER pour répondre à la question de l'utilisateur. Si tu l'as déjà recherché, garde la définition initiale et n'effectue pas de recherche.
93
- - Utilisation: { "action": "search_models", "params": { "query": "mots-clés de la recherche" } }
94
-
95
- 1. **search**: Pour chercher des informations dans les données de l'utilisateur.
84
+
85
+ Tu as accès aux outils et actions suivants.
86
+
87
+ OUTILS DE RAISONNEMENT INTERNE: (Utilisés pour collecter de l'information avant de décider de l'action finale)
88
+ 1. **search_models**: Pour rechercher les modèles de données disponibles.
89
+ - Utilisation: { "action": "search_models", "params": { "query": "^regexToSearchFor$" } }
90
+ ====
91
+
92
+ ACTIONS FINALES: (Actions qui terminent ta réflexion et renvoient un résultat à l'utilisateur)
93
+ 1. **search**: Pour chercher des informations et les AFFICHER à l'utilisateur sous forme de tableau.
96
94
  - Utilisation: { "action": "search", "params": { "model": "nomDuModele", "filter": {}, "limit": 10 } }
97
95
 
98
- 2. **post**: Pour créer une nouvelle donnée.
96
+ 2. **post**: Pour créer une nouvelle donnée (nécessite confirmation).
99
97
  - Utilisation: { "action": "post", "params": { "model": "nomDuModele", "data": {} } }
100
98
 
101
- 3. **update**: Pour mettre à jour des données existantes.
99
+ 3. **update**: Pour mettre à jour des données existantes (nécessite confirmation).
102
100
  - Utilisation: { "action": "update", "params": { "model": "nomDuModele", "filter": {}, "data": {} } }
103
101
  - filter est très pratique pour mettre à jour des données ciblées en une seule passe.
104
102
 
105
- 4. **delete**: Pour supprimer des données.
103
+ 4. **delete**: Pour supprimer des données (nécessite confirmation).
106
104
  - Utilisation: { "action": "delete", "params": { "model": "nomDuModele", "filter": {} } }
107
105
 
108
- 5. **displayMessage**: Pour répondre directement à l'utilisateur. N'utilise cette action QUE lorsque tu as toutes les informations nécessaires pour formuler une réponse finale.
106
+ 5. **displayMessage**: Pour répondre avec un simple message texte. N'utilise cette action QUE lorsque tu as toutes les informations nécessaires pour formuler une réponse finale.
109
107
  - Utilisation: { "action": "displayMessage", "params": { "message": "Ta réponse textuelle." } }
108
+
109
+ 6. **generateChart**: Pour créer et afficher un graphique.
110
+ - Utilisation: { "action": "generateChart", "params": { ...config } }
111
+ - Le paramètre \`config\` doit contenir :
112
+ - \`title\` (string): Un titre clair pour le graphique.
113
+ - \`model\` (string): Le nom du modèle de données à utiliser.
114
+ - \`type\` (string): Le type de graphique. Peut être 'bar', 'line', 'pie', 'doughnut'.
115
+ - \`aggregationType\` (string): Comment agréger les données. 'count' (par défaut), 'sum', 'avg', 'min', 'max'.
116
+ - \`xAxis\` (string): (Pour bar/line) Le champ pour l'axe des X. Un champ de type date, enum ou string du modèle.
117
+ - \`groupBy\` (string): (Pour pie/doughnut) Le champ sur lequel grouper les données (un champ enum ou relation du modèle).
118
+ - \`yAxis\` (string): (Optionnel, sauf pour sum/avg/min/max) Le champ numérique à agréger du modèle.
119
+ - \`filter\` (object): (Optionnel, filtre de la recherche, même écriture stricte que pour les filtres de recherche (voir plus bas pour les exemples)
110
120
 
111
121
  Voici le mémo pour assigner des valeurs aux champs des modèles,avec ces types de données :
112
122
  utilise une chaine de caractère convertible en ObjectId (mongodb) lorsque le nom du champ est _id
@@ -125,15 +135,14 @@ utilise la valeur en héxadecimal, ex: '#FF0000' pour les champs de type : color
125
135
  utilise les valeurs de cron standard '* * * * * *' pour : cronSchedule
126
136
 
127
137
  PROCESSUS DE RAISONNEMENT:
128
- 1. L'utilisateur pose une question.
129
- 2. Si le nom du modèle n'est pas évident, utilise d'abord l'outil "search_models" pour trouver le bon modèle.
130
- 3. Une fois le modèle identifié, utilise les autres outils (search, post, etc.) pour effectuer l'action.
131
- 4. Si tu as assez d'informations, réponds à l'utilisateur avec "displayMessage".
132
-
133
- Chacune de tes réponses doit être une étape en soi, ou au plus près d'une étape.
138
+ a- L'utilisateur pose une question, ou demande une action de ta part.
139
+ b- Utilise l'outil **search_models** pour trouver le(s) modèle(s) qui correspondent à la question.
140
+ Si tu as déjà fait la recherche dans la conversation, garde la définition initiale et n'effectue pas de recherche, va directement à l'étape c
141
+ c- Une fois la réponse retournée et intégrée, tu devras utiliser les autres outils (search, post, etc.) dans la conversation pour satisfaire la question initiale, en utilisant les informations des modèles précédents (COMMANDE FINALE)
142
+ Si tu n'as aucune commande à exécuter directement, réponds simplement à l'utilisateur avec "displayMessage".
134
143
 
135
144
  CONTEXTE ACTUEL:
136
- - Date du jour de ton conversateur : ${dt}
145
+ - Date du jour de la conversation : ${dt}
137
146
  - La langue ISO à utiliser dans la conversation : ${lang}
138
147
  - L'utilisateur a accès aux modèles de données suivants et ne peut utiliser les filtres que sur les champs associés:
139
148
  Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
@@ -143,38 +152,91 @@ Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
143
152
 
144
153
  Par exemple :
145
154
  Question : Je voudrais les événements non terminés, qui ne sont pas des festivals ou des salons :
146
- Réponse : { "action" : "search", "params" : ${cond1} }
155
+ Ta réponse: { "action" : "search_models", "params": { "query": "event|événement" } }
156
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond1} }
147
157
 
148
158
  Question : Donne moi les 5 nouvelles entreprises
149
- Réponse : { "action" : "search", "params" : ${cond2} }
159
+ Ta réponse: { "action" : "search_models", "params": { "query": "company|entreprise" } }
160
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond2} }
150
161
 
151
162
  Question : Je veux les 10 dernières traductions ajoutées dans la langue française.
152
- Réponse : { "action" : "search", "params" : ${cond3} }
163
+ Ta réponse: { "action" : "search_models", "params": { "query": "translation|traduction" } }
164
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond3} }
153
165
 
154
166
  Question : Je veux les commandes qui ont été faites par un admin ou un modérateur
155
- Réponse : { "action" : "search", "params" : ${cond4} }
156
- ====
167
+ Ta réponse: { "action" : "search_models", "params": { "query": "order|commande" } }
168
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond4} }
169
+
170
+ Question : Fais-moi un camembert des produits par catégorie.
171
+ Ta réponse: { "action" : "search_models", "params": { "query": "product|produit" } }
172
+ COMMANDE FINALE :
173
+ {
174
+ "action": "generateChart",
175
+ "params": {
176
+ "title": "Répartition des produits par catégorie",
177
+ "model": "product",
178
+ "type": "pie",
179
+ "groupBy": "category",
180
+ "aggregationType": "count",
181
+ "filter": { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
182
+ }
183
+ }
157
184
 
185
+ Absolute rules:
186
+ ====
158
187
  NEVER use the non-aggregated syntax of the MongoDB operators :
159
- Use { '$gt': ["$publishedAt", "2023-10-05T20:12:00Z" } } } and NOT { publishedAt: { '$gt': '2023-10-05T20:12:00Z' } } }
188
+ Use { "$gt": ["$publishedAt", "2023-10-05T20:12:00Z" ] } and NOT { "publishedAt": { "$gt": "2023-10-05T20:12:00Z" } }
189
+ =====
190
+ If several filters must be combined or juxtaposed, the operators $and, $or, $nor, $not MUST be used to make the logic.
191
+ You need at least one of these operators.
192
+ Example :
193
+ Use filters like this: { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
194
+ You MUST NOT write filters like this : {"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"], "$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} without the $and operator
160
195
  =====
161
196
  Et si tu dois utiliser une date :
162
197
  "2025-08-05T20:12:00Z" au lieu de { "$date": "2025-08-05T20:12:00Z" }
163
198
  =====
164
- Si plusieurs filtres doivent être combinés, utilise OBLIGATOIREMENT les opérateurs $and, $or, $nor, $not
165
- =====
166
-
167
- Règles ABSOLUES:
168
199
  - UNE SEULE COMMANDE JSON PAR RÉPONSE
169
200
  - AUCUN TEXTE HORS DU JSON
201
+ - PAS de MARKDOWN pour formatter le JSON, juste le JSON {...} brut.
202
+ =====
170
203
 
171
204
  Exemple d'échange correct :
172
205
 
173
206
  Ma question: Bonjour, je voudrais les requêtes effectuées aujourd'hui sur le modèle "content".
174
- Ta réponse:
207
+ Ta réponse: { "action" : "search_models", "params": { "query": "request" } }
208
+ COMMANDE FINALE :
175
209
  { "action" : "search", "params" : { "model": "request", "filter": { "$and": [{"$gte": "${dt}"}, {"$regexMatch": { "input": "$url", "regex": "content"}}] }, "limit" : 10, "sort" : "_id:DESC" }}`;
176
210
  }
177
211
 
212
+ /**
213
+ * Corrige un filtre généré par l'IA qui pourrait avoir plusieurs opérateurs
214
+ * au premier niveau sans les encapsuler dans un "$and".
215
+ * @param {object} filter - Le filtre potentiellement incorrect.
216
+ * @returns {object} Le filtre corrigé.
217
+ */
218
+ function correctAIFilter(filter) {
219
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
220
+ return filter; // Pas un objet à corriger
221
+ }
222
+
223
+ const keys = Object.keys(filter);
224
+ if (keys.length <= 1) {
225
+ return filter; // Rien à corriger
226
+ }
227
+
228
+ // Vérifie si toutes les clés sont des opérateurs (commencent par '$')
229
+ const allKeysAreOperators = keys.every(key => key.startsWith('$'));
230
+
231
+ if (allKeysAreOperators) {
232
+ logger.warn(`[Assistant] Correction d'un filtre malformé généré par l'IA. Original: ${JSON.stringify(filter)}`);
233
+ const andConditions = keys.map(key => ({ [key]: filter[key] }));
234
+ return { '$and': andConditions };
235
+ }
236
+
237
+ return filter; // Aucune correction nécessaire
238
+ }
239
+
178
240
  /**
179
241
  * Exécute un outil (tool) non-modifiant et retourne le résultat sous forme de chaîne.
180
242
  * @param {string} action - L'action à exécuter ('search', 'search_models').
@@ -216,7 +278,7 @@ async function executeTool(action, params, user, allModels) {
216
278
 
217
279
  if (foundModels.length > 0) {
218
280
  return "J'ai trouvé les modèles suivants qui pourraient correspondre : " +
219
- foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}`).join('');
281
+ foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}\n- Champs: ${m.fields.map(f => JSON.stringify(f, null, 2))}`).join('');
220
282
  } else {
221
283
  return "Je n'ai trouvé aucun modèle correspondant à votre recherche.";
222
284
  }
@@ -286,7 +348,7 @@ async function handleChatRequest(message, history, provider, context, user, conf
286
348
  // --- PRÉPARATION DE L'HISTORIQUE DE CONVERSATION ---
287
349
  const systemPrompt = createSystemPrompt([], user.lang || 'en');
288
350
  const conversationHistory = history
289
- .filter(msg => !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
351
+ .filter(msg => msg.text && !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
290
352
  .map(msg => new (msg.from === 'user' ? HumanMessage : SystemMessage)(msg.text));
291
353
 
292
354
  conversationHistory.unshift(new SystemMessage(systemPrompt));
@@ -317,11 +379,38 @@ async function handleChatRequest(message, history, provider, context, user, conf
317
379
  };
318
380
  }
319
381
 
320
- logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}`);
382
+ logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}`, parsedResponse);
321
383
  conversationHistory.push(new SystemMessage(JSON.stringify(parsedResponse)));
322
384
 
323
385
  const { action, params } = parsedResponse;
324
386
 
387
+ // Correction automatique du filtre généré par l'IA, qui hallucine parfois
388
+ if (params && params.filter) {
389
+ logger.debug(`[Assistant] Filtre original de l'IA: ${JSON.stringify(params.filter)}`);
390
+ params.filter = correctAIFilter(params.filter);
391
+ }
392
+
393
+ // Action de génération de graphique, gérée par le front-end
394
+ if (action === 'generateChart') {
395
+ // On retourne directement la configuration du graphique au client.
396
+ return { success: true, chartConfig: params };
397
+ }
398
+
399
+ // NOUVEAU: Action de recherche à afficher, gérée par le front-end
400
+ if (action === 'search') {
401
+ const searchResult = await searchData({
402
+ model: params.model,
403
+ filter: params.filter,
404
+ limit: params.limit || 10,
405
+ sort: params.sort
406
+ }, user);
407
+ if (searchResult.data?.length > 0) {
408
+ return { success: true, dataResult: { model: params.model, data: searchResult.data } };
409
+ } else {
410
+ return { success: true, displayMessage: i18n.t('assistant.search.noResults', "Je n'ai trouvé aucun résultat.") };
411
+ }
412
+ }
413
+
325
414
  // Actions nécessitant une confirmation de l'utilisateur
326
415
  if (['post', 'update', 'delete'].includes(action)) {
327
416
  const confirmationMessage = i18n.t('assistant.confirmActionPrompt', "Veuillez confirmer l'action suivante :");
@@ -337,8 +426,8 @@ async function handleChatRequest(message, history, provider, context, user, conf
337
426
  return { success: true, displayMessage: params.message };
338
427
  }
339
428
 
340
- // Actions à exécuter immédiatement (outils)
341
- if (['search', 'search_models'].includes(action)) {
429
+ // Outils pour le raisonnement interne de l'IA
430
+ if (['search_models'].includes(action)) { // On a enlevé 'search' d'ici
342
431
  const toolResult = await executeTool(action, params, user, allModels);
343
432
  conversationHistory.push(new SystemMessage(`Résultat de l'outil '${action}':\n${toolResult}`));
344
433
  continue; // On continue la boucle pour que l'IA puisse raisonner avec ce nouveau résultat
@@ -12,6 +12,7 @@ import i18n from "../../src/i18n.js";
12
12
  import {sendEmail} from "../email.js";
13
13
  import {throttleMiddleware} from "../middlewares/throttle.js";
14
14
  import {getCollectionForUser} from "./mongodb.js";
15
+ import {safeAssignObject} from "../core.js";
15
16
 
16
17
  const restoreRequests = {};
17
18
 
@@ -21,11 +22,11 @@ export const requestRestore = async (user, lang) => {
21
22
  const modelsRestoreToken = crypto.randomBytes(32).toString('hex');
22
23
  const expiration = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
23
24
 
24
- restoreRequests[user?.username] = {
25
+ safeAssignObject(restoreRequests, user?.username, {
25
26
  fullToken: fullRestoreToken,
26
27
  modelsToken: modelsRestoreToken,
27
28
  expiresAt: expiration
28
- };
29
+ });
29
30
 
30
31
  i18n.changeLanguage(lang);
31
32
 
@@ -0,0 +1,374 @@
1
+ import {dbUrl, MongoClient, MongoDatabase} from "../../engine.js";
2
+ import {getUserId, isDemoUser} from "../../data.js";
3
+ import {Config} from "../../config.js";
4
+ import {Event} from "../../events.js";
5
+ import {getObjectHash} from "../../core.js";
6
+ import path from "node:path";
7
+ import {downloadFromS3, getUserS3Config, listS3Backups, uploadToS3} from "../bucket.js";
8
+ import fs from "node:fs";
9
+ import {modelsCache, runCryptoWorkerTask} from "./data.core.js";
10
+ import * as tar from "tar";
11
+ import {getCollection, getUserCollectionName, modelsCollection} from "../mongodb.js";
12
+ import {removeFile} from "../file.js";
13
+ import {cancelAlerts, scheduleAlerts} from "./data.scheduling.js";
14
+ import {dbName} from "../../constants.js";
15
+ import {scheduleWorkflowTriggers} from "../workflow.js";
16
+ import crypto from "node:crypto";
17
+ import process from "node:process";
18
+ import {promisify} from "node:util";
19
+ import {execFile} from "node:child_process";
20
+ import AWS from "aws-sdk";
21
+ import {Logger} from "../../gameObject.js";
22
+
23
+ const getBackupDir = () => process.env.BACKUP_DIR || './backups'; // Répertoire de stockage des sauvegardes
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ let engine, logger;
27
+ export function onInit(defaultEngine) {
28
+ engine = defaultEngine;
29
+ logger = engine.getComponent(Logger);
30
+ }
31
+
32
+ export const jobDumpUserData = async () => {
33
+
34
+ try {
35
+
36
+ const primalsDb = MongoClient.db("primals");
37
+
38
+ let usersCollection = primalsDb.collection("users");
39
+ const users = await usersCollection.find().toArray();
40
+
41
+ users.forEach((user) => {
42
+ if (isDemoUser(user) && Config.Get("useDemoAccounts"))
43
+ return;
44
+ try {
45
+ dumpUserData(user).catch(e => {
46
+ Event.Trigger("OnUserDataDumped", "event", "system", engine);
47
+ })
48
+ } catch (ignored) {
49
+
50
+ }
51
+ });
52
+
53
+ } catch (e) {
54
+ console.error(e);
55
+ }
56
+ }
57
+ let restoreRequests = {};
58
+ export const validateRestoreRequest = (username, token) => {
59
+ const request = restoreRequests[username];
60
+ if (!request) {
61
+ return {error: 'Invalid username.'};
62
+ }
63
+
64
+ if (request.token !== token) {
65
+ return {error: 'Invalid token.'};
66
+ }
67
+
68
+ if (request.expiresAt < new Date()) {
69
+ delete restoreRequests[username];
70
+ return {error: 'Token has expired.'};
71
+ }
72
+
73
+ delete restoreRequests[username]; // Remove the request after validation
74
+ return {success: true};
75
+ };
76
+ export const loadFromDump = async (user, options = {}) => {
77
+ const {modelsOnly = false} = options;
78
+ const action = modelsOnly ? 'restore-models' : 'full-restore';
79
+ logger.info(`[${action}] Starting for user: ${user.username}`);
80
+
81
+ const encryptedKey = readKeyFromFile(user);
82
+ if (!encryptedKey) {
83
+ throw new Error("No encryption key found for this user. Cannot restore.");
84
+ }
85
+
86
+ const userId = getObjectHash({user: user.username});
87
+ const backupDir = getBackupDir();
88
+ let backupFilePath = ''; // Will hold the path to the archive to be restored
89
+ let isTempFile = false; // Flag to know if we need to delete the file later
90
+
91
+ const tmpRestoreDir = path.join(backupDir, `tmp_restore_${userId}_${Date.now()}`);
92
+
93
+ try {
94
+ const s3Config = await getUserS3Config(user);
95
+ // --- NEW LOGIC: Check for S3 config first ---
96
+ if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
97
+ logger.info(`[${action}] S3 config found for user. Searching for backups in bucket: ${s3Config.bucketName}`);
98
+
99
+ const s3Backups = await listS3Backups(s3Config);
100
+ const userBackups = s3Backups
101
+ .filter(f => f.filename.startsWith(`backup_${userId}_`))
102
+ .sort((a, b) => b.timestamp - a.timestamp);
103
+
104
+ if (userBackups.length === 0) {
105
+ throw new Error(`No S3 backups found for user ${user.username} in bucket ${s3Config.bucketName}.`);
106
+ }
107
+
108
+ const latestBackup = userBackups[0];
109
+ logger.info(`[${action}] Found latest S3 backup: ${latestBackup.key}. Downloading...`);
110
+
111
+ // Download the file to a temporary location
112
+ backupFilePath = path.join(backupDir, latestBackup.filename);
113
+ isTempFile = true;
114
+ await downloadFromS3(s3Config, latestBackup.key, backupFilePath);
115
+ logger.info(`[${action}] S3 backup downloaded to ${backupFilePath}.`);
116
+
117
+ } else {
118
+ // --- FALLBACK LOGIC: Look for local backups ---
119
+ logger.info(`[${action}] No S3 config. Searching for local backups.`);
120
+ const backupFilenameRegex = new RegExp(`^backup_${userId}_(\\d+)\\.tar\\.gz$`);
121
+ const backupFiles = fs.readdirSync(backupDir).filter(filename => backupFilenameRegex.test(filename));
122
+ if (backupFiles.length === 0) {
123
+ throw new Error(`No local backup files found for user ${user.username}.`);
124
+ }
125
+ const latestBackupFile = backupFiles.sort((a, b) => parseInt(b.match(backupFilenameRegex)[1], 10) - parseInt(a.match(backupFilenameRegex)[1], 10))[0];
126
+ backupFilePath = path.join(backupDir, latestBackupFile);
127
+ }
128
+
129
+ // --- The rest of the logic remains the same, operating on backupFilePath ---
130
+
131
+ await runCryptoWorkerTask('decrypt', {filePath: backupFilePath, password: encryptedKey});
132
+
133
+ if (!fs.existsSync(tmpRestoreDir)) {
134
+ fs.mkdirSync(tmpRestoreDir, {recursive: true});
135
+ }
136
+ await tar.extract({file: backupFilePath, gzip: true, C: tmpRestoreDir, sync: true});
137
+
138
+ // ... (Cleaning logic: deleteMany, removeFile, cancelAlerts) ...
139
+ const datasCollection = getCollection("datas");
140
+ if (modelsOnly) {
141
+ await modelsCollection.deleteMany({_user: user.username});
142
+ } else {
143
+ await datasCollection.deleteMany({_user: user.username});
144
+ await modelsCollection.deleteMany({_user: user.username});
145
+
146
+ const filesCollection = getCollection("files");
147
+ const userFiles = await filesCollection.find({user: user.username, _model: "privateFile"}).toArray();
148
+ for (const file of userFiles) {
149
+ await removeFile(file.guid, user).catch(e => logger.error(e.message));
150
+ }
151
+ await cancelAlerts(user);
152
+ logger.info(`[${action}] Cleaned existing data, models, files, and alerts for user ${user.username}.`);
153
+ }
154
+
155
+ // --- EXÉCUTION DE MONGORESTORE ---
156
+ const restoreSourceDir = path.join(tmpRestoreDir, dbName);
157
+ if (!fs.existsSync(restoreSourceDir)) {
158
+ throw new Error(`Restore source directory (${restoreSourceDir}) not found.`);
159
+ }
160
+
161
+ let command;
162
+ const args = [
163
+ '--uri', dbUrl,
164
+ '--db', dbName
165
+ ];
166
+
167
+ if (modelsOnly) {
168
+ args.push('--nsInclude', `${dbName}.models`);
169
+ } else {
170
+ // mongorestore accepte plusieurs fois l'option --nsInclude
171
+ args.push('--nsInclude', `${dbName}.datas`);
172
+ args.push('--nsInclude', `${dbName}.models`);
173
+ }
174
+ // Le répertoire source est le dernier argument
175
+ args.push(restoreSourceDir);
176
+
177
+
178
+ logger.info(`[${action}] Executing restore command: ${command}`);
179
+ await execFileAsync('mongorestore', args);
180
+
181
+ // ... (Post-restore tasks) ...
182
+ await scheduleAlerts();
183
+ await scheduleWorkflowTriggers();
184
+ modelsCache.flushAll();
185
+
186
+ logger.info(`[${action}] Restore successful for user ${user.username}.`);
187
+ await Event.Trigger("OnDataRestored", "event", "system");
188
+
189
+ } finally {
190
+ // --- GUARANTEED CLEANUP ---
191
+ if (fs.existsSync(tmpRestoreDir)) {
192
+ await fs.promises.rm(tmpRestoreDir, {recursive: true, force: true});
193
+ }
194
+
195
+ // Re-encrypt the original file if it's not a temporary one
196
+ if (fs.existsSync(backupFilePath) && !isTempFile) {
197
+ await runCryptoWorkerTask('encrypt', {filePath: backupFilePath, password: encryptedKey});
198
+ }
199
+
200
+ // If we downloaded a temp file from S3, delete it
201
+ if (fs.existsSync(backupFilePath) && isTempFile) {
202
+ fs.unlinkSync(backupFilePath);
203
+ logger.info(`[${action}] Deleted temporary downloaded backup file: ${backupFilePath}`);
204
+ }
205
+ }
206
+ };
207
+ // Fonction pour générer une clé aléatoire et la stocker dans un fichier
208
+ const generateAndStoreKey = (user) => {
209
+ const backupDir = getBackupDir();
210
+ const keyFile = path.join(backupDir, getObjectHash({id: getUserId(user)}) + '_encryption.key');
211
+ const key = crypto.randomBytes(16).toString('hex');
212
+ fs.writeFileSync(keyFile, key, {mode: 0o600}); // Permissions strictes
213
+ return key;
214
+ };
215
+ // Fonction pour lire la clé depuis le fichier
216
+ const readKeyFromFile = (user) => {
217
+ const backupDir = getBackupDir();
218
+ const keyFile = path.join(backupDir, getObjectHash({id: getUserId(user)}) + '_encryption.key');
219
+ if (fs.existsSync(keyFile)) {
220
+ return fs.readFileSync(keyFile, 'utf8');
221
+ }
222
+ return null;
223
+ };
224
+ export const dumpUserData = async (user) => {
225
+ const s3Config = await getUserS3Config(user);
226
+ const backupDir = getBackupDir();
227
+ const userId = getObjectHash({user: user.username});
228
+ const backupFilename = `backup_${userId}`;
229
+ const timestamp = Date.now();
230
+
231
+ // Déclarer les chemins ici pour qu'ils soient accessibles dans tout le scope de la fonction
232
+ const localTempDumpDir = path.join(backupDir, `${backupFilename}_${timestamp}_temp`);
233
+ const finalArchiveName = `${backupFilename}_${timestamp}.tar.gz`;
234
+ const localArchivePath = path.join(backupDir, finalArchiveName);
235
+
236
+ let encryptedKey = readKeyFromFile(user);
237
+ if (!encryptedKey) {
238
+ encryptedKey = generateAndStoreKey(user);
239
+ }
240
+
241
+ try {
242
+ const backupFrequency = await engine.userProvider.getBackupFrequency(user);
243
+ logger.info(`Fréquence de sauvegarde : ${backupFrequency}.`);
244
+
245
+ const collections = await MongoDatabase.listCollections().toArray();
246
+ for (const collection of collections) {
247
+ const collsToBackup = [await getUserCollectionName(user), 'models'];
248
+ if (collsToBackup.includes(collection.name)) {
249
+ const query = {_user: user.username};
250
+ const args = [
251
+ '--uri', dbUrl,
252
+ '--db', dbName,
253
+ '--out', localTempDumpDir,
254
+ '--collection', collection.name,
255
+ '--query', JSON.stringify(query)
256
+ ];
257
+ logger.info(`Exécution de la commande : mongodump ${args.join(' ')}`);
258
+ await execFileAsync('mongodump', args);
259
+ }
260
+ }
261
+ const dumpSourceDir = path.join(localTempDumpDir, dbName);
262
+ if (fs.existsSync(dumpSourceDir)) {
263
+ await tar.create({gzip: true, file: localArchivePath, C: localTempDumpDir}, [dbName]);
264
+ logger.info(`Archive de sauvegarde locale créée : ${localArchivePath}`);
265
+ } else {
266
+ logger.warn(`Le répertoire de dump ${dumpSourceDir} était vide. Aucune archive n'a créée.`);
267
+ return Promise.resolve();
268
+ }
269
+
270
+ await runCryptoWorkerTask('encrypt', {filePath: localArchivePath, password: encryptedKey});
271
+
272
+ try {
273
+ // Attempt the S3 upload
274
+ await uploadToS3(s3Config, localArchivePath, finalArchiveName);
275
+ // ONLY if the upload succeeds, delete the local file.
276
+ fs.unlinkSync(localArchivePath);
277
+ logger.info(`Local archive ${finalArchiveName} deleted after successful S3 upload.`);
278
+ } catch (e) {
279
+ }
280
+
281
+ logger.info(`Sauvegarde réussie pour l'utilisateur ${user.username}.`);
282
+ await manageBackupRotation(user, await engine.userProvider.getBackupFrequency(user), s3Config);
283
+
284
+ } catch (error) {
285
+ logger.error(`Erreur lors de la sauvegarde pour l'utilisateur ${user.username}:`, error);
286
+ // Nettoyage de l'archive si elle a été créée avant l'erreur
287
+ if (fs.existsSync(localArchivePath)) {
288
+ fs.unlinkSync(localArchivePath);
289
+ }
290
+ throw error; // Relancer l'erreur pour que l'appelant soit informé
291
+ } finally {
292
+ // --- NETTOYAGE GARANTI ---
293
+ // Ce bloc s'exécute toujours, que la sauvegarde réussisse ou échoue.
294
+ if (fs.existsSync(localTempDumpDir)) {
295
+ fs.rmSync(localTempDumpDir, {recursive: true, force: true});
296
+ logger.info(`Répertoire de dump temporaire ${localTempDumpDir} supprimé.`);
297
+ }
298
+ }
299
+ };
300
+
301
+ async function manageBackupRotation(user, backupFrequency, s3Config = null) { // Accepter s3Config
302
+ const userId = getObjectHash({user: user.username});
303
+ let filesToManage = [];
304
+
305
+ if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
306
+ logger.info(`Gestion de la rotation des sauvegardes S3 pour ${userId}.`);
307
+ const s3Backups = await listS3Backups(s3Config);
308
+ // Filtrer pour ne garder que les backups de cet utilisateur et trier
309
+ filesToManage = s3Backups
310
+ .filter(f => f.filename.startsWith(`backup_${userId}_`) && f.filename.endsWith('.tar.gz'))
311
+ .map(f => ({
312
+ name: f.filename,
313
+ key: f.key,
314
+ timestamp: f.timestamp
315
+ })) // listS3Backups devrait fournir le timestamp
316
+ .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant (plus récent en premier)
317
+
318
+ } else {
319
+ logger.info(`Gestion de la rotation des sauvegardes locales pour ${userId}.`);
320
+ const backupDir = getBackupDir();
321
+ const localFiles = fs.readdirSync(backupDir);
322
+ filesToManage = localFiles
323
+ .filter(f => !fs.lstatSync(path.join(backupDir, f)).isDirectory() && f.startsWith(`backup_${userId}_`) && f.endsWith('.tar.gz'))
324
+ .map(f => {
325
+ const match = f.match(/_(\d+)\.tar\.gz$/);
326
+ return {name: f, key: path.join(backupDir, f), timestamp: match ? parseInt(match[1], 10) : 0};
327
+ })
328
+ .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant
329
+ }
330
+
331
+ let maxFilesToKeep;
332
+ // ... (ta logique existante pour maxFilesToKeep basée sur backupFrequency)
333
+ switch (backupFrequency) {
334
+ case 'daily': // Premium
335
+ maxFilesToKeep = 7; // Garder 7 jours
336
+ break;
337
+ case 'weekly': // Standard
338
+ maxFilesToKeep = 4; // Garder 4 semaines
339
+ break;
340
+ case 'monthly': // Free
341
+ default:
342
+ maxFilesToKeep = 2; // Garder 2 mois
343
+ break;
344
+ }
345
+ logger.info(`Rotation pour ${userId}: fréquence ${backupFrequency}, garde ${maxFilesToKeep} sauvegardes.`);
346
+
347
+
348
+ if (filesToManage.length > maxFilesToKeep) {
349
+ const filesToDelete = filesToManage.slice(maxFilesToKeep);
350
+ logger.info(`Suppression de ${filesToDelete.length} anciennes sauvegardes pour ${userId}.`);
351
+
352
+ const deletionPromises = filesToDelete.map(async (fileInfo) => {
353
+ try {
354
+ if (s3Config && s3Config.bucketName) {
355
+ const s3 = new AWS.S3({ /* ... config ... */
356
+ accessKeyId: s3Config.accessKeyId,
357
+ secretAccessKey: s3Config.secretAccessKey,
358
+ region: s3Config.region
359
+ });
360
+ await s3.deleteObject({Bucket: s3Config.bucketName, Key: fileInfo.key}).promise();
361
+ logger.info(`Ancienne sauvegarde S3 supprimée : ${fileInfo.key}`);
362
+ } else {
363
+ await fs.promises.unlink(fileInfo.key); // key est le chemin complet pour les fichiers locaux
364
+ logger.info(`Ancienne sauvegarde locale supprimée : ${fileInfo.name}`);
365
+ }
366
+ } catch (err) {
367
+ logger.error(`Erreur lors de la suppression de l'ancienne sauvegarde ${fileInfo.name || fileInfo.key}:`, err);
368
+ }
369
+ });
370
+ await Promise.allSettled(deletionPromises);
371
+ } else {
372
+ logger.info(`Aucune ancienne sauvegarde à supprimer pour ${userId} (total: ${filesToManage.length}, garde: ${maxFilesToKeep}).`);
373
+ }
374
+ }