data-primals-engine 1.0.0

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.
@@ -0,0 +1,296 @@
1
+ // server/src/modules/assistant.js
2
+
3
+ import { getCollectionForUser, modelsCollection } from "./mongodb.js";
4
+ import { Logger } from "../gameObject.js";
5
+ import { ChatOpenAI } from "@langchain/openai";
6
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
7
+ import { HumanMessage, SystemMessage } from "@langchain/core/messages";
8
+ import {searchData, patchData, deleteData, insertData} from "./data.js";
9
+ import { getDataAsString } from "../data.js";
10
+ import i18n from "../../../data-primals-engine/src/i18n.js";
11
+ import {generateLimiter} from "./user.js";
12
+ import rateLimit from "express-rate-limit";
13
+
14
+ let logger = null;
15
+
16
+ export const assistantGlobalLimiter = rateLimit({
17
+ windowMs: 15 * 60 * 1000, // Fenêtre de 15 minutes
18
+ max: 100, // Limite à 100 requêtes globales pour l'assistant pendant la fenêtre (vous pouvez ajuster cette valeur)
19
+ standardHeaders: true, // Active les en-têtes standard `RateLimit-*`
20
+ legacyHeaders: false, // Désactive les anciens en-têtes `X-RateLimit-*`
21
+ message: { success: false, message: "Trop de requêtes globales envoyées à l'assistant. Veuillez réessayer plus tard." },
22
+ skip: (req) => {
23
+ return !!req.body?.confirmedAction;
24
+ }
25
+ });
26
+
27
+ const createSystemPrompt = (modelDefs, conditionBuilderExample) =>`
28
+ Tu es "Prior", un assistant expert en analyse de données pour la plateforme data.primals.net.
29
+ Ta mission est d'aider l'utilisateur en répondant à ses questions sur ses données.
30
+
31
+ STRICTEMENT INTERDIT :
32
+ - DE FAIRE DES COMMENTAIRES TEXTUELS
33
+ - D'EXPLIQUER CE QUE TU VAS FAIRE
34
+ - D'UTILISER DU TEXTE LIBRE SOUS AUCUNE FORME
35
+
36
+ FORMAT DE RÉPONSE OBLIGATOIRE :
37
+ Un SEUL objet JSON valide contenant exactement 2 champs :
38
+ 1. "action" (string)
39
+ 2. "params" (object)
40
+
41
+ Tu as accès aux outils suivants. Tu ne dois utiliser QUE ces outils.
42
+
43
+ 1. **search**: Pour chercher des informations dans les données de l'utilisateur.
44
+ - Utilisation: \`{ "action": "search", "params": { "model": "nomDuModele", "filter": $FILTER, "limit": 10 } }\`
45
+
46
+ 2. **post**: Pour créer une nouvelle donnée.
47
+ - Utilisation: \`{ "action": "post", "params": { "model": "nomDuModele", "data": $DATA } }\`
48
+
49
+ 3. **update**: Pour mettre à jour des données existantes.
50
+ - Utilisation: \`{ "action": "update", "params": { "model": "nomDuModele", "filter": $FILTER, "data": $DATA } }\` filter est très pratique pour mettre à jour des données ciblées en une seule passe.
51
+
52
+ 4. **delete**: Pour supprimer des données.
53
+ - Utilisation: \`{ "action": "delete", "params": { "model": "nomDuModele", "filter": $FILTER } }\`
54
+
55
+ 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.
56
+ - Utilisation: \`{ "action": "displayMessage", "params": { "message": "Ta réponse textuelle." } }\`
57
+
58
+ La spécification suivante peut t'aider à construire les filtres) est la suivante :
59
+ utilise une chaine de caracteres lorsque le type de champ est : code, string, string_t , password, url, phone, email, richtext
60
+ utilise une structure { "iso2langcode":"content..." } pour le champ multi-traductions richtext_t
61
+ utilise un booléen pour : boolean
62
+ utilise un nombre pour number
63
+ utilise une date au format ISO String pour les types de champ : date, datetime,
64
+ utilise les valeurs de l'attribut items pour les champs de type : enum
65
+ utilise un tableau de données brutes pour les champs de type : array
66
+ utilise un _id nécessairement pour remplir les champs relation multiple=false
67
+ utilise un tableau d'_ids pour remplir les champs relation multiple=true
68
+ utilise la valeur en héxadecimal, ex: '#FF0000' pour les champs de type : color
69
+ utilise les valeurs de cron standard '* * * * * *' pour : cronSchedule
70
+
71
+ PROCESSUS DE RAISONNEMENT:
72
+ 1. L'utilisateur pose une question.
73
+ 2. Tu analyses la question et choisis l'outil approprié.
74
+ 3. Tu réponds IMMÉDIATEMENT avec l'objet JSON correspondant SANS COMMENTAIRE.
75
+
76
+ CONTEXTE ACTUEL:
77
+ - L'utilisateur a accès aux modèles de données suivants et ne peut utiliser les filtres que sur les champs associés:
78
+ ${modelDefs.map(m => ` - Modèle "${m.name}":\n Champs: ${JSON.stringify(m.fields.map(f => ({ name: f.name, type: f.type, hint: f.hint })), null, 2)}`).join('\n')}
79
+ - Le format pour les filtres ($FILTER) est : ${conditionBuilderExample}
80
+ - Le format de $DATA est { modelFieldName: "value", otherModelFieldName: { subObj : true } }
81
+
82
+
83
+ Règles ABSOLUES:
84
+ - UNE SEULE COMMANDE JSON PAR RÉPONSE
85
+ - AUCUN TEXTE HORS DU JSON
86
+ - SI TU N'APPLIQUES PAS CES RÈGLES, LE SYSTÈME TE DÉSACTIVERA
87
+
88
+ Exemple de réponse CORRECTE :
89
+ \`\`\`
90
+ json
91
+ {
92
+ "action" : "search",
93
+ "params" : {
94
+ "model": "request", "limit" : 10, "sort" : "_id:DESC"
95
+ }
96
+ }
97
+ \`\`\`
98
+
99
+ Exemple de réponse INCORRECTE (INTERDIT) :
100
+ "Je vais d'abord rechercher ces requêtes. Voici la commande que je vais exécuter..."`;
101
+
102
+
103
+ /**
104
+ * Gère la requête de chat, soit en exécutant une action confirmée,
105
+ * soit en lançant la boucle de raisonnement de l'IA.
106
+ */
107
+ async function handleChatRequest(message, history, provider, context, user, confirmedAction) {
108
+
109
+ // --- GESTION D'UNE ACTION CONFIRMÉE ---
110
+ if (confirmedAction && confirmedAction.action) {
111
+ try {
112
+ const result = await executeConfirmedAction(confirmedAction.action, confirmedAction.params, user);
113
+ let successMessage = i18n.t('assistant.actionSuccess', "Action exécutée avec succès.");
114
+ if (result.insertedIds) successMessage = i18n.t('assistant.itemCreated', `Élément créé avec l'ID: {{id}}.`, { id: result.insertedIds.join(', ') });
115
+ if (result.modifiedCount) successMessage = i18n.t('assistant.itemsUpdated', `{{count}} élément(s) mis à jour.`, { count: result.modifiedCount });
116
+ if (result.deletedCount) successMessage = i18n.t('assistant.itemsDeleted', `{{count}} élément(s) supprimés.`, { count: result.deletedCount });
117
+
118
+ return { success: true, displayMessage: successMessage };
119
+ } catch (error) {
120
+ logger.error(`[Assistant] Erreur lors de l'exécution de l'action confirmée: ${error.message}`, error.stack);
121
+ return { success: false, displayMessage: i18n.t('error', `Erreur : {{message}}`, { message: error.message }) };
122
+ }
123
+ }
124
+ // --- FIN DE LA GESTION DE CONFIRMATION ---
125
+
126
+ const { modelName } = context;
127
+
128
+ // --- Étape 1: Initialisation de l'IA ---
129
+ let apiKey;
130
+ let p = provider || 'openai';
131
+ const providers = { "openai": "OPENAI_API_KEY", "google": "GOOGLE_API_KEY" };
132
+ const envKeyName = providers[p];
133
+ if (!envKeyName) return { success: false, message: `Fournisseur IA non supporté : ${p}` };
134
+
135
+ const envCollection = getCollectionForUser(user);
136
+ const userEnvVar = await envCollection.findOne({ _model: 'env', name: envKeyName, _user: user.username });
137
+ apiKey = userEnvVar?.value || process.env[envKeyName];
138
+
139
+ if (!apiKey) return { success: false, message: `Clé API pour ${provider} (${envKeyName}) non trouvée.` };
140
+
141
+ let llm;
142
+ try {
143
+ llm = p === 'openai'
144
+ ? new ChatOpenAI({ apiKey, modelName: "gpt-4o-mini", temperature: 0.2, response_format: { "type": "json_object" } })
145
+ : new ChatGoogleGenerativeAI({ apiKey, modelName: "gemini-1.5-pro-latest", temperature: 0.2, response_format: { "type": "json_object" } });
146
+ } catch (initError) {
147
+ return { success: false, message: `Initialisation du client IA pour ${p}: ${initError.message}` };
148
+ }
149
+
150
+ // --- Étape 2: Préparation du contexte pour l'IA ---
151
+ const allModels = await modelsCollection.find({ $or: [{ _user: { $exists: false } }, { _user: user.username }] }).toArray();
152
+ const relevantModelNames = new Set([modelName, 'alert', 'kpi', "dashboard", "request", "translation"]);
153
+ const modelDefs = allModels.filter(m => relevantModelNames.has(m.name));
154
+
155
+ const conditionBuilderExample = JSON.stringify({ "$and": [{ "$eq": ["$fieldName", "value"] }] }, null, 2);
156
+ const systemPrompt = createSystemPrompt(modelDefs, conditionBuilderExample);
157
+
158
+ const conversationHistory = history
159
+ .filter(msg => !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
160
+ .map(msg => new (msg.from === 'user' ? HumanMessage : SystemMessage)(msg.text));
161
+
162
+ conversationHistory.unshift(new SystemMessage(systemPrompt));
163
+ conversationHistory.push(new HumanMessage(message));
164
+
165
+ const maxTurns = 5;
166
+
167
+ // --- Étape 3: La boucle de raisonnement ---
168
+ for (let i = 0; i < maxTurns; i++) {
169
+ logger.debug(`[Assistant] Tour de boucle ${i + 1}. Invocation de l'IA...`);
170
+
171
+ const response = await llm.invoke(conversationHistory);
172
+ const llmOutput = response.content;
173
+
174
+ let parsedResponse;
175
+ try {
176
+ parsedResponse = JSON.parse(llmOutput);
177
+ if (!parsedResponse.action || !parsedResponse.params) {
178
+ throw new Error("Format de réponse invalide.");
179
+ }
180
+ } catch (parseError) {
181
+ // Si ce n'est pas du JSON valide, on nettoie et affiche
182
+ const cleanedOutput = llmOutput.replace(/\{(?:[^{}]|(?:\{[^{}]*\}))*\}/g, '').trim();
183
+ return {
184
+ success: true,
185
+ displayMessage: cleanedOutput || i18n.t('assistant.invalidResponse', "Réponse non valide.")
186
+ };
187
+ }
188
+
189
+ // Exécution IMMÉDIATE des actions sans confirmation (sauf pour post/update/delete)
190
+ switch (parsedResponse.action) {
191
+ case 'search': {
192
+ const searchResult = await searchData({
193
+ user,
194
+ query: {
195
+ model: parsedResponse.params.model,
196
+ filter: parsedResponse.params.filter,
197
+ limit: parsedResponse.params.limit || 10
198
+ }
199
+ });
200
+
201
+ const resultString = searchResult.data.length > 0
202
+ ? i18n.t('assistant.searchResults', "Voici les résultats :") +
203
+ searchResult.data.map(item =>
204
+ `\n- ${getDataAsString(allModels.find(m => m.name === parsedResponse.params.model), item, i18n.t, allModels, true)}`
205
+ ).join('')
206
+ : i18n.t('assistant.noResults', "Aucun résultat trouvé.");
207
+
208
+ return { success: true, displayMessage: resultString };
209
+ }
210
+
211
+ case 'displayMessage':
212
+ return { success: true, displayMessage: parsedResponse.params.message };
213
+
214
+ case 'code':
215
+ return { success: true, displayMessage: "```json\n" + JSON.stringify(parsedResponse.params.message, null, 2) + "\n```" };
216
+
217
+ case 'post':
218
+ case 'update':
219
+ case 'delete':
220
+ return {
221
+ success: true,
222
+ displayMessage: i18n.t('assistant.confirmAction', `Je vais exécuter : ${parsedResponse.action}. Confirmez-vous ?`),
223
+ confirmationRequest: parsedResponse
224
+ };
225
+
226
+ default:
227
+ return {
228
+ success: true,
229
+ displayMessage: i18n.t('assistant.unknownAction', "Commande non reconnue.")
230
+ };
231
+ }
232
+ }
233
+
234
+ logger.warn("[Assistant] La boucle a atteint le nombre maximum de tours sans réponse.");
235
+ return { success: true, displayMessage: i18n.t('assistant.loopTimeout', "Désolé, je n'ai pas réussi à terminer ma pensée. Pouvez-vous reformuler votre demande ?"), actions: [] };
236
+ }
237
+
238
+ /**
239
+ * Exécute une action de modification (post, update, delete) après confirmation de l'utilisateur.
240
+ * @param {string} action - L'action à exécuter ('post', 'update', 'delete').
241
+ * @param {object} params - Les paramètres de l'action.
242
+ * @param {object} user - L'objet utilisateur.
243
+ * @returns {Promise<object>} - Le résultat de l'opération de base de données.
244
+ */
245
+ async function executeConfirmedAction(action, params, user) {
246
+ logger.info(`[Assistant] Exécution de l'action confirmée par l'utilisateur: ${action}`);
247
+ switch (action) {
248
+ case 'post':
249
+ // Note : on passe false pour ne pas redéclencher de workflow ici
250
+ return await insertData(params.model, params.data, {}, user, false, false);
251
+ case 'update':
252
+ return await patchData(params.model, params.filter, params.data, {}, user);
253
+ case 'delete':
254
+ // Le modèle est dans les params, pas besoin de le passer en argument sparé
255
+ return await deleteData(params.model, [], params.filter, user);
256
+ default:
257
+ throw new Error(`Action confirmée non supportée: ${action}`);
258
+ }
259
+ }
260
+
261
+
262
+ export async function onInit(engine) {
263
+ logger = engine.getComponent(Logger);
264
+
265
+ const {middlewareAuthenticator, userInitiator} = await import('./user.js');
266
+
267
+ engine.post('/api/assistant/chat', [middlewareAuthenticator, userInitiator, assistantGlobalLimiter, generateLimiter], async (req, res) => {
268
+ // On récupère TOUTES les propriétés du body, y compris l'action confirmée
269
+ const {message, history, provider, context, confirmedAction} = req.body;
270
+
271
+ // La validation ne s'applique que s'il n'y a pas d'action confirmée
272
+ if (!confirmedAction) {
273
+ if (typeof (message) !== 'string' || !message.trim()) {
274
+ return res.status(400).json({
275
+ success: false,
276
+ message: i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["message"])
277
+ });
278
+ }
279
+ if (message.length > 4096) {
280
+ return res.status(400).json({
281
+ success: false,
282
+ message: 'Le message ne doit pas dépasser 4096 caractères.'
283
+ });
284
+ }
285
+ }
286
+
287
+ try {
288
+ const result = await handleChatRequest(message, history, provider, context, req.me, confirmedAction);
289
+ res.json(result);
290
+ } catch (error) {
291
+ logger.error(`[Endpoint /api/assistant/chat] Erreur inattendue: ${error.message}`, error.stack);
292
+ res.status(500).json({success: false, message: "Une erreur interne est survenue."});
293
+ }
294
+ });
295
+ logger.info("Module 'assistant' initialisé et endpoint '/api/assistant/chat' enregistré.");
296
+ }
@@ -0,0 +1,233 @@
1
+ import process from "node:process";
2
+ import AWS from "aws-sdk";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import {decryptValue, encryptValue} from "../data.js";
6
+ import {MongoClient} from "../engine.js";
7
+ import {loadFromDump, validateRestoreRequest} from "./data.js";
8
+ import {Logger} from "../gameObject.js";
9
+ import {middlewareAuthenticator, myFreePremiumAnonymousLimiter, userInitiator} from "./user.js";
10
+ import {awsDefaultConfig, maxBytesPerSecondThrottleData} from "../constants.js";
11
+ import crypto from "node:crypto";
12
+ import i18n from "../../../data-primals-engine/src/i18n.js";
13
+ import {sendEmail} from "../email.js";
14
+ import {throttleMiddleware} from "../middlewares/throttle.js";
15
+
16
+ const restoreRequests = {};
17
+
18
+ export const requestRestore = async (user, lang) => {
19
+ // On génère deux tokens uniques
20
+ const fullRestoreToken = crypto.randomBytes(32).toString('hex');
21
+ const modelsRestoreToken = crypto.randomBytes(32).toString('hex');
22
+ const expiration = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
23
+
24
+ restoreRequests[user?.username] = {
25
+ fullToken: fullRestoreToken,
26
+ modelsToken: modelsRestoreToken,
27
+ expiresAt: expiration,
28
+ };
29
+
30
+ i18n.changeLanguage(lang);
31
+
32
+ try {
33
+ // On utilise une nouvelle clé de traduction pour l'email
34
+ await sendEmail(user.email, {
35
+ title: i18n.t('email.backup.restoreRequest.subject'),
36
+ content: i18n.t('email.backup.restoreRequest.content', {
37
+ user: user?.username,
38
+ fullToken: fullRestoreToken,
39
+ modelsToken: modelsRestoreToken
40
+ })
41
+ });
42
+ return { message: 'Restore links sent to your email.' };
43
+ } catch (error) {
44
+ console.error('Error sending email:', error);
45
+ throw new Error('Error sending email.');
46
+ }
47
+ };
48
+
49
+ const getDefaultS3Config = () => {
50
+ return {
51
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
52
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // Utiliser la clé déchiffrée
53
+ region: process.env.AWS_REGION || awsDefaultConfig.region,
54
+ bucketName: process.env.AWS_BUCKET || awsDefaultConfig.bucketName,
55
+ };
56
+ }
57
+ const getS3Client = (s3Config) => {
58
+
59
+ const decryptedSecretAccessKey = decryptValue(s3Config.secretAccessKey);
60
+
61
+ const defaultConfig = getDefaultS3Config();
62
+
63
+ return new AWS.S3({
64
+ ...defaultConfig,
65
+ accessKeyId: s3Config.accessKeyId || defaultConfig.accessKeyId,
66
+ secretAccessKey: decryptedSecretAccessKey ? decryptedSecretAccessKey : defaultConfig.secretAccessKey,
67
+ region: s3Config.region || defaultConfig.region,
68
+ bucket: s3Config.bucketName || defaultConfig.bucketName
69
+ });
70
+ };
71
+
72
+ export const uploadToS3 = async (s3Config, filePath, remoteFilename) => {
73
+ const s3 = getS3Client(s3Config);
74
+ const fileContent = fs.readFileSync(filePath);
75
+ const bucketPath = s3Config.pathPrefix ? `${s3Config.pathPrefix.replace(/\/$/, "")}/${remoteFilename}` : remoteFilename;
76
+
77
+ const params = {
78
+ Bucket: s3Config.bucketName,
79
+ Key: bucketPath,
80
+ Body: fileContent,
81
+ };
82
+
83
+ try {
84
+ const data = await s3.upload(params).promise();
85
+ console.log(`File uploaded successfully to S3. ${data.Location}`);
86
+ return data;
87
+ } catch (err) {
88
+ console.error("Error uploading to S3:", err);
89
+ throw err;
90
+ }
91
+ };
92
+
93
+ export const listS3Backups = async (s3Config) => {
94
+ const s3 = getS3Client(s3Config);
95
+ const bucketPathPrefix = s3Config.pathPrefix ? `${s3Config.pathPrefix.replace(/\/$/, "")}/` : '';
96
+
97
+ const params = {
98
+ Bucket: s3Config.bucketName,
99
+ Prefix: bucketPathPrefix,
100
+ };
101
+
102
+ try {
103
+ const data = await s3.listObjectsV2(params).promise();
104
+ return data.Contents.map(item => ({
105
+ key: item.Key,
106
+ lastModified: item.LastModified,
107
+ size: item.Size,
108
+ // Tu peux extraire le nom du fichier et le timestamp de la clé si nécessaire
109
+ filename: path.basename(item.Key),
110
+ timestamp: parseInt(path.basename(item.Key).split('_').pop().split('.')[0], 10) || 0
111
+ })).sort((a, b) => b.timestamp - a.timestamp); // Trier par timestamp descendant
112
+ } catch (err) {
113
+ console.error("Error listing S3 backups:", err);
114
+ throw err;
115
+ }
116
+ };
117
+
118
+ export const downloadFromS3 = async (s3Config, s3FileKey, downloadPath) => {
119
+ const s3 = getS3Client(s3Config);
120
+ const params = {
121
+ Bucket: s3Config.bucketName,
122
+ Key: s3FileKey,
123
+ };
124
+
125
+ try {
126
+ const data = await s3.getObject(params).promise();
127
+ fs.writeFileSync(downloadPath, data.Body);
128
+ console.log(`File downloaded successfully from S3 to ${downloadPath}`);
129
+ return downloadPath;
130
+ } catch (err) {
131
+ console.error("Error downloading from S3:", err);
132
+ throw err;
133
+ }
134
+ };
135
+
136
+ const throttle = throttleMiddleware(maxBytesPerSecondThrottleData);
137
+
138
+ let engine, logger;
139
+ export async function onInit(defaultEngine) {
140
+ engine = defaultEngine;
141
+ logger = engine.getComponent(Logger);
142
+
143
+ engine.post('/api/backup/request-restore', [throttle, middlewareAuthenticator, userInitiator, myFreePremiumAnonymousLimiter], async (req, res) => {
144
+ const user = req.me; // Assuming you have user authentication middleware
145
+ if (!user) {
146
+ return res.status(401).json({ error: 'Unauthorized' });
147
+ }
148
+
149
+ try {
150
+ const result = await requestRestore(user);
151
+ res.json(result);
152
+ } catch (error) {
153
+ console.error('Error requesting restore:', error);
154
+ res.status(500).json({ error: error.message });
155
+ }
156
+ });
157
+ engine.get('/api/backup/restore', [throttle, middlewareAuthenticator, userInitiator], async (req, res) => {
158
+ const { token, username } = req.query;
159
+
160
+ if (!token || !username) {
161
+ return res.status(400).json({ error: 'Token and username are required.' });
162
+ }
163
+
164
+ const validationResult = validateRestoreRequest(username, token);
165
+
166
+ if (validationResult.error) {
167
+ return res.status(400).json({ error: validationResult.error });
168
+ }
169
+
170
+ try {
171
+ await loadFromDump({username});
172
+ res.json({ message: 'Backup restoration successful.' });
173
+ delete restoreRequests[username];
174
+ } catch (error) {
175
+ console.error('Error restoring backup:', error);
176
+ res.status(500).json({ error: 'Error restoring backup.' });
177
+ }
178
+ });
179
+
180
+ engine.post('/api/user/s3-config', [middlewareAuthenticator, userInitiator, myFreePremiumAnonymousLimiter], async (req, res) => {
181
+ const user = req.me;
182
+ const { bucketName, accessKeyId, secretAccessKey, region, pathPrefix } = req.body;
183
+
184
+ // Validation basique des entrées
185
+ if (!bucketName || !accessKeyId || !region) {
186
+ return res.status(400).json({ success: false, error: "Bucket name, Access Key ID, and Region are required." });
187
+ }
188
+ // Autres validations possibles (longueur, format de la région, etc.)
189
+
190
+ try {
191
+ const primalsDb = MongoClient.db("primals"); // Assure-toi que MongoClient est bien ton client MongoDB connecté
192
+ const usersCollection = primalsDb.collection("users");
193
+
194
+ const updateData = {
195
+ 's3Config.bucketName': bucketName,
196
+ 's3Config.accessKeyId': accessKeyId, // Stocké en clair (généralement acceptable)
197
+ 's3Config.region': region,
198
+ 's3Config.pathPrefix': pathPrefix || '', // S'assurer qu'il y a une valeur par défaut si vide
199
+ };
200
+
201
+ // Chiffrer et mettre à jour la clé secrète uniquement si elle est fournie
202
+ if (secretAccessKey) {
203
+ updateData['s3Config.secretAccessKey'] = encryptValue(secretAccessKey); // Chiffrer avant de stocker
204
+ } else {
205
+ // Si la clé secrète n'est pas fournie, on ne la modifie pas.
206
+ // Si tu veux permettre de la supprimer, il faudrait une logique explicite.
207
+ // Pour l'instant, on ne touche pas à s3Config.secretAccessKey si req.body.secretAccessKey est vide.
208
+ }
209
+
210
+ const result = await usersCollection.updateOne(
211
+ { username: user.username }, // ou user._id si c'est ce que tu utilises comme identifiant unique
212
+ { $set: updateData }
213
+ );
214
+
215
+ if (result.modifiedCount > 0 || result.matchedCount > 0) { // Succès même si rien n'a été modifié (déjà les bonnes valeurs)
216
+ logger.info(`S3 configuration updated for user ${user.username}`);
217
+ res.json({ success: true, message: "S3 configuration updated successfully." });
218
+ } else {
219
+ logger.warn(`S3 configuration update: User ${user.username} not found or no changes made.`);
220
+ // Si l'utilisateur n'est pas trouvé, c'est une erreur 404
221
+ const userExists = await usersCollection.findOne({ username: user.username });
222
+ if (!userExists) {
223
+ return res.status(404).json({ success: false, error: "User not found." });
224
+ }
225
+ res.json({ success: true, message: "S3 configuration already up to date." });
226
+ }
227
+
228
+ } catch (error) {
229
+ logger.error(`Error updating S3 configuration for user ${user.username}:`, error);
230
+ res.status(500).json({ success: false, error: "An internal server error occurred while updating S3 configuration." });
231
+ }
232
+ });
233
+ }