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,1296 @@
1
+ // Exemple conceptuel dans la fonction de planification
2
+ import {getCollection, getCollectionForUser, isObjectId} from "./mongodb.js";
3
+ import schedule from "node-schedule";
4
+ import {ObjectId} from "mongodb";
5
+ import crypto from "node:crypto";
6
+
7
+ import {Logger} from "../gameObject.js";
8
+ import {deleteData, editData, insertData, patchData, searchData} from "./data.js";
9
+ import {maxExecutionsByStep, maxWorkflowSteps} from "../constants.js";
10
+ import { ChatOpenAI } from "@langchain/openai";
11
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
12
+ import { ChatPromptTemplate } from "@langchain/core/prompts";
13
+ import i18n from "../../../data-primals-engine/src/i18n.js";
14
+ import {sendEmail} from "../email.js";
15
+
16
+ // 1. ADD THIS IMPORT AT THE TOP OF THE FILE
17
+ // This allows the module to call its own exported functions.
18
+ import * as workflowModule from './workflow.js';
19
+
20
+ let logger = null;
21
+ export async function onInit(defaultEngine) {
22
+ logger = defaultEngine.getComponent(Logger);
23
+ }
24
+
25
+ /**
26
+ * Exécute une fonction de manière sécurisée en s'assurant qu'une seule instance
27
+ * s'exécute à la fois, grâce à un système de verrouillage distribué basé sur la base de données.
28
+ * Cette fonction est atomique et conçue pour éviter les conditions de course.
29
+ *
30
+ * @param {string} jobId - Un identifiant unique pour la tâche (ex: 'workflowTrigger_monId').
31
+ * @param {Function} jobFunction - La fonction asynchrone à exécuter si le verrou est acquis.
32
+ * @param {number} [lockDurationMinutes=5] - La durée en minutes pendant laquelle le verrou est considéré comme valide.
33
+ * @returns {Promise<void>}
34
+ */
35
+ export async function runScheduledJobWithDbLock(jobId, jobFunction, lockDurationMinutes = 5) {
36
+ const jobsCollection = getCollection('job_locks');
37
+ const now = new Date();
38
+ const lockExpiresAt = new Date(now.getTime() + lockDurationMinutes * 60 * 1000);
39
+ let lockAcquired = false; // Drapeau pour savoir si nous devons libérer le verrou
40
+
41
+ // Le bloc try...finally garantit que la libération du verrou est tentée
42
+ // même si la fonction jobFunction lève une exception.
43
+ try {
44
+ // --- PHASE 1: ACQUISITION DU VERROU (de manière atomique) ---
45
+
46
+ // Tentative 1: Mettre à jour un verrou existant qui a expiré.
47
+ // C'est le cas le plus courant après la première exécution.
48
+ // L'opération `updateOne` est atomique.
49
+ const updateResult = await jobsCollection.updateOne(
50
+ {
51
+ jobId: jobId,
52
+ lockedUntil: { $lt: now } // Le verrou est disponible si sa date d'expiration est dans le passé
53
+ },
54
+ {
55
+ $set: { lockedUntil: lockExpiresAt, lastStarted: now },
56
+ $inc: { runCount: 1 }
57
+ }
58
+ );
59
+
60
+ if (updateResult.modifiedCount === 1) {
61
+ // Succès : nous avons mis à jour le verrou expiré et l'avons acquis.
62
+ lockAcquired = true;
63
+ logger.info(`[Lock] Verrou existant acquis pour la tâche ${jobId}.`);
64
+ } else {
65
+ // Si aucun document n'a été modifié, soit le verrou n'existe pas,
66
+ // soit il est actuellement détenu par un autre processus.
67
+ // Tentative 2: Insérer un nouveau document de verrou.
68
+ // Cette opération échouera avec une erreur de clé dupliquée (code 11000)
69
+ // si un autre processus a réussi à créer le verrou entre-temps.
70
+ try {
71
+ await jobsCollection.insertOne({
72
+ jobId: jobId,
73
+ lockedUntil: lockExpiresAt,
74
+ lastStarted: now,
75
+ runCount: 1
76
+ });
77
+ // Succès : nous avons créé un nouveau verrou et l'avons acquis.
78
+ lockAcquired = true;
79
+ logger.info(`[Lock] Nouveau verrou créé pour la tâche ${jobId}.`);
80
+ } catch (insertError) {
81
+ if (insertError.code === 11000) {
82
+ // Comportement attendu : un autre processus a acquis le verrou.
83
+ // Ce n'est pas une erreur, on saute simplement l'exécution.
84
+ logger.info(`[Lock] Impossible d'acquérir le verrou pour ${jobId}, un autre processus le dent. Exécution ignorée.`);
85
+ } else {
86
+ // Une erreur de base de données inattendue s'est produite.
87
+ throw insertError;
88
+ }
89
+ }
90
+ }
91
+
92
+ // --- PHASE 2: EXÉCUTION DE LA TÂCHE ---
93
+ if (lockAcquired) {
94
+ logger.info(`[Lock] Exécution de la fonction pour la tâche ${jobId}...`);
95
+ await jobFunction();
96
+ logger.info(`[Lock] La fonction pour la tâche ${jobId} s'est terminée.`);
97
+ }
98
+
99
+ } catch (error) {
100
+ // Capture les erreurs de la `jobFunction` ou les erreurs inattendues de la base de données.
101
+ logger.error(`Erreur durant l'exécution de la tâche verrouillée ${jobId}:`, error);
102
+ } finally {
103
+ // --- PHASE 3: LIBÉRATION DU VERROU ---
104
+ if (lockAcquired) {
105
+ try {
106
+ // On libère le verrou en mettant sa date d'expiration dans le passé,
107
+ // le rendant immédiatement disponible pour la prochaine exécution.
108
+ await jobsCollection.updateOne(
109
+ { jobId: jobId },
110
+ { $set: { lockedUntil: new Date(0) } }
111
+ );
112
+ logger.info(`[Lock] Verrou libéré pour la tâche ${jobId}.`);
113
+ } catch (releaseError) {
114
+ // Il est crucial de logger cette erreur, car un verrou non libéré peut bloquer les futures exécutions.
115
+ logger.error(`CRITIQUE: Échec de la libération du verrou pour la tâche ${jobId}. Une intervention manuelle peut être nécessaire.`, releaseError);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+
122
+ /**
123
+ * Planifie l'exécution des workflows déclenchés par une cronExpression.
124
+ * Utilise runScheduledJobWithDbLock pour assurer l'exécution unique à travers plusieurs instances.
125
+ */
126
+ export async function scheduleWorkflowTriggers() {
127
+ logger.info('Starting scheduling of workflow triggers...');
128
+ try {
129
+ const datasCollection = getCollection('datas'); // Ou la collection appropriée pour les workflows
130
+
131
+ // Trouver tous les workflows actifs avec une cronExpression définie
132
+ const workflowsToSchedule = await datasCollection.find({
133
+ _model: 'workflowTrigger',
134
+ cronExpression: { $exists: true, $ne: "" },
135
+ // Ajoutez d'autres conditions si nécessaire (ex: active: true)
136
+ }).toArray();
137
+
138
+ console.log(`Found ${workflowsToSchedule.length} workflow triggers with cron expressions to schedule.`);
139
+
140
+ for (const workflow of workflowsToSchedule) {
141
+ const jobId = `workflowTrigger_${workflow._id}`; // ID unique pour le verrou du job
142
+ const cronExpression = workflow.cronExpression;
143
+ if( !cronExpression )
144
+ continue;
145
+ // Planifier la tâche en utilisant node-schedule
146
+ schedule.scheduleJob(cronExpression, async () => {
147
+ console.log(`Cron triggered for job ${jobId}. Attempting to run with lock...`);
148
+
149
+ // Utiliser runScheduledJobWithDbLock pour exécuter la tâche
150
+ await runScheduledJobWithDbLock(
151
+ jobId,
152
+ async () => {
153
+ // --- Début de la logique spécifique au workflow ---
154
+ // C'est ici que vous mettriez le code qui doit être exécuté
155
+ // lorsque le workflow est déclenché par le cron.
156
+ // Par exemple:
157
+ console.log(`Executing task logic for workflow ${workflow.name} (ID: ${workflow._id})`);
158
+
159
+ // Exemple:
160
+ // const targetModel = workflow.targetModel;
161
+ // const action = workflow.action;
162
+ // await executeWorkflowAction(targetModel, action, workflow.parameters);
163
+
164
+ // Simule une tâche asynchrone
165
+ await new Promise(resolve => setTimeout(resolve, 2000));
166
+
167
+ console.log(`Task logic completed for workflow ${workflow.name} (ID: ${workflow._id})`);
168
+ // --- Fin de la logique spécifique au workflow ---
169
+ },
170
+ workflow.lockDurationMinutes || 5 // Utilise la durée du workflow ou une valeur par défaut
171
+ );
172
+ });
173
+
174
+ }
175
+
176
+ console.log('Finished scheduling workflow triggers.');
177
+
178
+ } catch (error) {
179
+ console.error('Error during scheduling of workflow triggers:', error);
180
+ }
181
+ }
182
+
183
+
184
+ /**
185
+ * Handles the 'Webhook' workflow action.
186
+ * Sends an HTTP request to a specified URL with substituted data using native fetch.
187
+ *
188
+ * @param {object} actionDef - The definition of the 'Webhook' action.
189
+ * @param {object} contextData - The current workflow run context data.
190
+ * @param {object} user - The user object (peut être utilisé pour l'authentification ou le logging).
191
+ * @param {object} dbCollection - The MongoDB collection (moins pertinent ici, mais gardé pour la cohérence).
192
+ * @returns {Promise<{success: boolean, message?: string, responseStatus?: number, responseBody?: any}>} - Result of the action.
193
+ */
194
+ async function handleWebhookAction(actionDef, contextData, user, dbCollection) {
195
+ const { name: actionName, _id: actionId, url, method = 'POST', headers: headersTemplate, body: bodyTemplate } = actionDef;
196
+
197
+ // 1. Basic Validation
198
+ if (!url) {
199
+ const msg = `[handleWebhookAction] Action ${actionName} (${actionId}): Missing 'url'.`;
200
+ logger.error(msg);
201
+ return { success: false, message: msg };
202
+ }
203
+
204
+ logger.info(`[handleWebhookAction] Action ${actionName} (${actionId}): Executing webhook. Method: ${method}`);
205
+
206
+ try {
207
+ // 2. Substitute Variables
208
+ const substitutedUrl = await substituteVariables(url, contextData, user);
209
+ let substitutedHeadersString;
210
+ let substitutedBodyString;
211
+ let headersObject = {};
212
+ let bodyObject = null;
213
+
214
+ // Substitute Headers (JSON string or object)
215
+ if (headersTemplate) {
216
+ if (typeof headersTemplate === 'string') {
217
+ substitutedHeadersString = await substituteVariables(headersTemplate, contextData, user);
218
+ } else if (typeof headersTemplate === 'object') {
219
+ headersObject = await substituteVariables(headersTemplate, contextData, user);
220
+ } else {
221
+ logger.warn(`[handleWebhookAction] Action ${actionName} (${actionId}): 'headers' has an invalid type (${typeof headersTemplate}). Ignoring.`);
222
+ }
223
+ }
224
+
225
+ // Substitute Body (JSON string or object) - only relevant for methods like POST, PUT, PATCH
226
+ if (bodyTemplate && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
227
+ if (typeof bodyTemplate === 'string') {
228
+ substitutedBodyString = await substituteVariables(bodyTemplate, contextData, user);
229
+ } else if (typeof bodyTemplate === 'object') {
230
+ bodyObject = await substituteVariables(bodyTemplate, contextData, user);
231
+ } else {
232
+ logger.warn(`[handleWebhookAction] Action ${actionName} (${actionId}): 'body' has an invalid type (${typeof bodyTemplate}). Ignoring.`);
233
+ }
234
+ }
235
+
236
+ // 3. Parse substituted JSON strings
237
+ if (substitutedHeadersString) {
238
+ try {
239
+ headersObject = JSON.parse(substitutedHeadersString);
240
+ if (typeof headersObject !== 'object' || headersObject === null) {
241
+ throw new Error("Parsed headers is not a valid object.");
242
+ }
243
+ } catch (parseError) {
244
+ logger.error(`[handleWebhookAction] Action ${actionName} (${actionId}): Failed to parse substituted 'headers' JSON. Error: ${parseError.message}. Using default headers. Substituted string: ${substitutedHeadersString}`);
245
+ headersObject = { 'Content-Type': 'application/json' }; // Fallback
246
+ }
247
+ }
248
+ // Ensure Content-Type if body is present and headers don't specify it
249
+ if (bodyObject !== null || substitutedBodyString) {
250
+ if (!headersObject['Content-Type'] && !headersObject['content-type']) {
251
+ headersObject['Content-Type'] = 'application/json';
252
+ }
253
+ }
254
+
255
+
256
+ if (substitutedBodyString) {
257
+ try {
258
+ // Try parsing first, maybe it's valid JSON already
259
+ bodyObject = JSON.parse(substitutedBodyString);
260
+ } catch (parseError) {
261
+ // If parsing fails, treat it as a plain string body
262
+ bodyObject = substitutedBodyString;
263
+ // Adjust Content-Type if it was assumed to be JSON
264
+ if (headersObject['Content-Type'] === 'application/json') {
265
+ headersObject['Content-Type'] = 'text/plain';
266
+ }
267
+ }
268
+ }
269
+
270
+ // 4. Prepare Fetch Options
271
+ const fetchOptions = {
272
+ method: method.toUpperCase(),
273
+ headers: headersObject, // Native fetch accepts an object directly
274
+ };
275
+
276
+ if (bodyObject !== null && ['POST', 'PUT', 'PATCH'].includes(fetchOptions.method)) {
277
+ // Stringify if it's an object and content type is JSON, otherwise use as is
278
+ if (typeof bodyObject === 'object' && headersObject['Content-Type'] === 'application/json') {
279
+ fetchOptions.body = JSON.stringify(bodyObject);
280
+ } else {
281
+ fetchOptions.body = bodyObject; // Use string directly
282
+ }
283
+ }
284
+
285
+ // 5. Execute Fetch Request using native fetch
286
+ logger.info(`[handleWebhookAction] Action ${actionName} (${actionId}): Calling URL: ${substitutedUrl}`);
287
+ const response = await fetch(substitutedUrl, fetchOptions); // Utilisation de fetch natif
288
+
289
+ // 6. Process Response
290
+ let responseBody;
291
+ const contentType = response.headers.get('content-type');
292
+ try {
293
+ if (contentType && contentType.includes('application/json')) {
294
+ responseBody = await response.json();
295
+ } else {
296
+ responseBody = await response.text();
297
+ }
298
+ } catch (responseParseError) {
299
+ logger.error(`[handleWebhookAction] Action ${actionName} (${actionId}): Failed to parse response body. Error: ${responseParseError.message}`);
300
+ // Try reading as text again in case of error during json parsing
301
+ try {
302
+ responseBody = await response.text();
303
+ } catch (textError) {
304
+ responseBody = "[Could not parse response body]";
305
+ }
306
+ }
307
+
308
+ logger.info(`[handleWebhookAction] Action ${actionName} (${actionId}): Received response. Status: ${response.status}`);
309
+
310
+ // 7. Return Result
311
+ if (response.ok) { // Status code 200-299
312
+ return {
313
+ success: true,
314
+ message: `Webhook executed successfully. Status: ${response.status}`,
315
+ responseStatus: response.status,
316
+ responseBody: responseBody
317
+ // updatedContext: { webhookResponse: responseBody } // Optionnel: Ajouter la réponse au contexte
318
+ };
319
+ } else {
320
+ // Handle non-successful responses (4xx, 5xx)
321
+ const errorMsg = `Webhook execution failed. Status: ${response.status}. Response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
322
+ logger.error(`[handleWebhookAction] Action ${actionName} (${actionId}): ${errorMsg}`);
323
+ return {
324
+ success: false,
325
+ message: errorMsg,
326
+ responseStatus: response.status,
327
+ responseBody: responseBody
328
+ };
329
+ }
330
+
331
+ } catch (error) {
332
+ // Catch network errors or other unexpected errors during the process
333
+ const msg = `[handleWebhookAction] Action ${actionName} (${actionId}): Unexpected error during webhook execution. Error: ${error.message}`;
334
+ logger.error(msg, error.stack);
335
+ return { success: false, message: msg };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Handles the 'CreateData' workflow action.
341
+ * Substitutes variables, validates, and inserts a new document.
342
+ *
343
+ * @param {object} actionDef - The definition of the 'CreateData' action.
344
+ * @param {object} contextData - The current workflow run context data.
345
+ * @param {object} user - The user object.
346
+ * @param {object} dbCollection - The MongoDB collection for the user.
347
+ * @returns {Promise<{success: boolean, message?: string, insertedId?: ObjectId}>} - Result of the action.
348
+ */
349
+ async function handleCreateDataAction(actionDef, contextData, user, dbCollection) {
350
+ const { targetModel, dataToCreate } = actionDef;
351
+
352
+ // 1. Basic Validation
353
+ if (!targetModel || typeof targetModel !== 'string') {
354
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
355
+ logger.error(msg);
356
+ return { success: false, message: msg };
357
+ }
358
+ if (!dataToCreate) {
359
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'dataToCreate' template.`;
360
+ logger.error(msg);
361
+ return { success: false, message: msg };
362
+ }
363
+
364
+ logger.info(`[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Creating data for model '${targetModel}'.`);
365
+
366
+ try {
367
+ // 2. Substitute Variables in the data template
368
+ let substitutedDataString;
369
+ let dataObject;
370
+
371
+ // dataToCreate might be a string (JSON) or already an object from the model definition
372
+ if (typeof dataToCreate === 'string') {
373
+ substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
374
+ console.log({substitutedDataString});
375
+ } else if (typeof dataToCreate === 'object') {
376
+ // If it's an object, substitute within its values
377
+ const substitutedObject = await substituteVariables(dataToCreate, contextData, user);
378
+ // We still need to stringify and parse if it was originally intended as JSON string
379
+ // Or assume it's meant to be used directly if it came as an object
380
+ // Let's assume direct use for now if it's an object in the definition
381
+ dataObject = substitutedObject;
382
+
383
+ console.log({substitutedObject});
384
+ } else {
385
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
386
+ logger.error(msg);
387
+ return { success: false, message: msg };
388
+ }
389
+
390
+
391
+ // 3. Parse the substituted data if it was a string
392
+ if (substitutedDataString) {
393
+ try {
394
+ dataObject = JSON.parse(substitutedDataString);
395
+ if (typeof dataObject !== 'object' || dataObject === null) {
396
+ throw new Error("Parsed data is not a valid object.");
397
+ }
398
+ } catch (parseError) {
399
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'dataToCreate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedDataString}`;
400
+ logger.error(msg);
401
+ return { success: false, message: msg };
402
+ }
403
+ }
404
+
405
+ const ids = await insertData(targetModel, dataObject, [], user, false);
406
+ return { success: true, insertedIds: ids };
407
+
408
+ } catch (error) {
409
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during creation for model '${targetModel}'. Error: ${error.message}`;
410
+ logger.error(msg, error.stack);
411
+ return { success: false, message: msg };
412
+ }
413
+ }
414
+
415
+
416
+ /**
417
+ * Handles the 'UpdateData' workflow action.
418
+ * Finds document(s) based on a selector, substitutes variables in updates,
419
+ * validates, and updates the document(s) using the updateData function.
420
+ *
421
+ * @param {object} actionDef - The definition of the 'UpdateData' action.
422
+ * @param {object} contextData - The current workflow run context data.
423
+ * @param {object} user - The user object.
424
+ * @param {object} dbCollection - The MongoDB collection for the user (bien que updateData utilise getCollectionForUser).
425
+ * @returns {Promise<{success: boolean, message?: string, modifiedCount?: number, matchedCount?: number}>} - Result of the action.
426
+ */
427
+ async function handleUpdateDataAction(actionDef, contextData, user) {
428
+ const { targetModel, targetSelector, fieldsToUpdate, updateMultiple = false } = actionDef; // updateMultiple optionnel, défaut false
429
+
430
+ // 1. Basic Validation
431
+ if (!targetModel || typeof targetModel !== 'string') {
432
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
433
+ logger.error(msg);
434
+ return { success: false, message: msg };
435
+ }
436
+ if (!targetSelector) {
437
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
438
+ logger.error(msg);
439
+ return { success: false, message: msg };
440
+ }
441
+ if (!fieldsToUpdate) {
442
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'fieldsToUpdate'.`;
443
+ logger.error(msg);
444
+ return { success: false, message: msg };
445
+ }
446
+
447
+ logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Updating data for model '${targetModel}'. Multiple: ${updateMultiple}`);
448
+
449
+ try {
450
+ // 2. Substitute Variables in selector and updates
451
+ let substitutedSelectorString;
452
+ let substitutedUpdatesString;
453
+ let selectorObject;
454
+ let updatesObject;
455
+
456
+ // Substitute targetSelector (assuming it's a JSON string or object)
457
+ if (typeof targetSelector === 'string') {
458
+ substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
459
+ } else if (typeof targetSelector === 'object') {
460
+ selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
461
+ } else {
462
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
463
+ logger.error(msg);
464
+ return { success: false, message: msg };
465
+ }
466
+
467
+ // Substitute fieldsToUpdate (assuming it's a JSON string or object)
468
+ if (typeof fieldsToUpdate === 'string') {
469
+ substitutedUpdatesString = await substituteVariables(fieldsToUpdate, contextData, user);
470
+ } else if (typeof fieldsToUpdate === 'object') {
471
+ updatesObject = await substituteVariables(fieldsToUpdate, contextData, user); // Substitute values within the object
472
+ } else {
473
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' has an invalid type (${typeof fieldsToUpdate}). Expected string (JSON) or object.`;
474
+ logger.error(msg);
475
+ return { success: false, message: msg };
476
+ }
477
+
478
+ // 3. Parse substituted JSON strings
479
+ if (substitutedSelectorString) {
480
+ try {
481
+ selectorObject = JSON.parse(substitutedSelectorString);
482
+ if (typeof selectorObject !== 'object' || selectorObject === null) {
483
+ throw new Error("Parsed selector is not a valid object.");
484
+ }
485
+ } catch (parseError) {
486
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
487
+ logger.error(msg);
488
+ return { success: false, message: msg };
489
+ }
490
+ }
491
+ if (substitutedUpdatesString) {
492
+ try {
493
+ updatesObject = JSON.parse(substitutedUpdatesString);
494
+ if (typeof updatesObject !== 'object' || updatesObject === null) {
495
+ throw new Error("Parsed updates is not a valid object.");
496
+ }
497
+ } catch (parseError) {
498
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'fieldsToUpdate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedUpdatesString}`;
499
+ logger.error(msg);
500
+ return { success: false, message: msg };
501
+ }
502
+ }
503
+
504
+ // Remove system fields potentially included in updates by mistake
505
+ delete updatesObject._id;
506
+ delete updatesObject._model;
507
+ delete updatesObject._user;
508
+ delete updatesObject._hash;
509
+
510
+ if (Object.keys(updatesObject).length === 0) {
511
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' resulted in an empty update object after substitution/parsing. Nothing to update.`;
512
+ logger.warn(msg);
513
+ return { success: true, message: "No fields to update.", modifiedCount: 0, matchedCount: 0 };
514
+ }
515
+
516
+ const updateResult = await patchData(
517
+ targetModel,
518
+ selectorObject,
519
+ updatesObject,
520
+ {},
521
+ user
522
+ );
523
+
524
+
525
+ console.log(
526
+ targetModel,
527
+ selectorObject,
528
+ updatesObject,
529
+ {},
530
+ user
531
+ );
532
+
533
+ // 6. Return result
534
+ if (updateResult.success || updateResult.unmodified) {
535
+ logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Update successful for model '${targetModel}'. Matched: ${updateResult.matchedCount}, Modified: ${updateResult.modifiedCount}`);
536
+ return {
537
+ success: true,
538
+ modifiedCount: updateResult.modifiedCount,
539
+ matchedCount: updateResult.matchedCount,
540
+ message: updateResult.message
541
+ };
542
+ } else {
543
+ // updateData now throws errors, so this 'else' might not be reached often,
544
+ // but kept for safety in case it returns { success: false } in some scenarios.
545
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): updateData function reported failure. Message: ${updateResult.error}`;
546
+ logger.error(msg);
547
+ return { success: false, message: msg };
548
+ }
549
+
550
+ } catch (error) {
551
+ // Catch errors thrown by updateData (validation, permissions, DB errors) or other unexpected errors
552
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during update for model '${targetModel}'. Error: ${error.message}`;
553
+ logger.error(msg, error.stack);
554
+ return { success: false, message: msg };
555
+ }
556
+ }
557
+
558
+
559
+
560
+ /**
561
+ * Handles the 'DeleteData' workflow action.
562
+ * Finds document(s) based on a selector, substitutes variables,
563
+ * and deletes the document(s) using the deleteData function.
564
+ *
565
+ * @param {object} actionDef - The definition of the 'DeleteData' action.
566
+ * @param {object} contextData - The current workflow run context data.
567
+ * @param {object} user - The user object.
568
+ * @param {object} dbCollection - The MongoDB collection for the user (bien que deleteData utilise getCollectionForUser).
569
+ * @returns {Promise<{success: boolean, message?: string, deletedCount?: number}>} - Result of the action.
570
+ */
571
+ async function handleDeleteDataAction(actionDef, contextData, user, dbCollection) {
572
+ // deleteMultiple optionnel, défaut false (supprime un seul par défaut)
573
+ const { targetModel, targetSelector, deleteMultiple = false } = actionDef;
574
+
575
+ // 1. Basic Validation
576
+ if (!targetModel || typeof targetModel !== 'string') {
577
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
578
+ logger.error(msg);
579
+ return { success: false, message: msg };
580
+ }
581
+ if (!targetSelector) {
582
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
583
+ logger.error(msg);
584
+ return { success: false, message: msg };
585
+ }
586
+
587
+ logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Deleting data for model '${targetModel}'. Multiple: ${deleteMultiple}`);
588
+
589
+ try {
590
+ // 2. Substitute Variables in selector
591
+ let substitutedSelectorString;
592
+ let selectorObject;
593
+
594
+ // Substitute targetSelector (assuming it's a JSON string or object)
595
+ if (typeof targetSelector === 'string') {
596
+ substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
597
+ } else if (typeof targetSelector === 'object') {
598
+ selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
599
+ } else {
600
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
601
+ logger.error(msg);
602
+ return { success: false, message: msg };
603
+ }
604
+
605
+ // 3. Parse substituted JSON string
606
+ if (substitutedSelectorString) {
607
+ try {
608
+ selectorObject = JSON.parse(substitutedSelectorString);
609
+ if (typeof selectorObject !== 'object' || selectorObject === null) {
610
+ throw new Error("Parsed selector is not a valid object.");
611
+ }
612
+ } catch (parseError) {
613
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
614
+ logger.error(msg);
615
+ return { success: false, message: msg };
616
+ }
617
+ }
618
+
619
+ // 5. Call the centralized deleteData function (à créer dans data.js)
620
+ // Cette fonction devra gérer la recherche préalable pour les workflows 'DataDeleted' et la suppression des fichiers.
621
+ const deleteResult = await deleteData(
622
+ targetModel, [],
623
+ selectorObject,
624
+ user
625
+ );
626
+
627
+ // 6. Return result
628
+ if (deleteResult.success) {
629
+ logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Delete successful for model '${targetModel}'. Deleted: ${deleteResult.deletedCount}`);
630
+ return {
631
+ success: true,
632
+ deletedCount: deleteResult.deletedCount,
633
+ message: deleteResult.message // Pass along messages like "not found"
634
+ };
635
+ } else {
636
+ // deleteData devrait lancer des erreurs, mais on garde ce else par sécurité.
637
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): deleteData function reported failure. Message: ${deleteResult.message}`;
638
+ logger.error(msg);
639
+ return { success: false, message: msg };
640
+ }
641
+
642
+ } catch (error) {
643
+ // Catch errors thrown by deleteData (permissions, DB errors) or other unexpected errors
644
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during deletion for model '${targetModel}'. Error: ${error.message}`;
645
+ logger.error(msg, error.stack);
646
+ return { success: false, message: msg };
647
+ }
648
+ }
649
+
650
+ // Dans workflow.js
651
+ export async function executeStepAction(actionDef, contextData, user, dbCollection) {
652
+ logger.info(`[executeStepAction] Executing action type ${actionDef.type} for action ${actionDef._id} (${actionDef.name})`);
653
+
654
+ try {
655
+ let result;
656
+ switch (actionDef.type) {
657
+ case 'Log':
658
+ logger.info(`[Workflow Log Action] Action: ${actionDef.name}. Contexte:`, contextData);
659
+ result = { success: true, message: 'Log action executed successfully.' }; // <--- CORRECTION
660
+ break;
661
+ case 'Webhook':
662
+ result = await handleWebhookAction(actionDef, contextData, user, dbCollection);
663
+ break;
664
+ case 'CreateData':
665
+ result = await handleCreateDataAction(actionDef, contextData, user, dbCollection);
666
+ break;
667
+ case 'UpdateData':
668
+ result = await handleUpdateDataAction(actionDef, contextData, user);
669
+ break;
670
+ case 'DeleteData':
671
+ result = await handleDeleteDataAction(actionDef, contextData, user, dbCollection);
672
+ break;
673
+ case 'GenerateAIContent':
674
+ result = await executeGenerateAIContentAction(actionDef, contextData, user);
675
+ break;
676
+ case 'SendEmail':
677
+ result = await handleSendEmailAction(actionDef, contextData, user);
678
+ break;
679
+ // ... autres cases à venir ...
680
+ default:
681
+ logger.error(`[executeStepAction] Unknown action type: ${actionDef.type}`);
682
+ return { success: false, message: `Unknown action type: ${actionDef.type}` };
683
+ }
684
+ return result;
685
+ } catch (error) {
686
+ logger.error(`[executeStepAction] Error executing action ${actionDef.name} (${actionDef._id}): ${error.message}`, error.stack);
687
+ return { success: false, message: error.message || 'Action execution failed' };
688
+ }
689
+ }
690
+ /**
691
+ * Récupère une valeur imbriquée dans un objet en utilisant une chaîne de chemin.
692
+ * Gère les tableaux et les objets. Retourne undefined si le chemin n'est pas trouvé.
693
+ * Exemple: getNestedValue({ a: { b: [ { c: 1 } ] } }, 'a.b.0.c') -> 1
694
+ *
695
+ * @param {object} obj L'objet source.
696
+ * @param {string} path La chaîne de chemin (ex: 'user.address.city').
697
+ * @returns {*} La valeur trouvée ou undefined.
698
+ */
699
+ function getNestedValue(obj, path) {
700
+ // Vérifie si l'objet ou le chemin est invalide
701
+ if (!obj || typeof path !== 'string') {
702
+ return undefined;
703
+ }
704
+ // Sépare le chemin en clés individuelles (ex: 'a.b.0.c' -> ['a', 'b', '0', 'c'])
705
+ const keys = path.split('.');
706
+ let current = obj; // Commence à la racine de l'objet
707
+
708
+ // Parcourt chaque clé dans le chemin
709
+ for (const key of keys) {
710
+ // Si à un moment donné on atteint null ou undefined, le chemin est invalide
711
+ if (current === null || current === undefined) {
712
+ return undefined;
713
+ }
714
+ // Récupère la valeur pour la clé actuelle
715
+ const value = current[key];
716
+ // Si la valeur est undefined, le chemin est invalide
717
+ if (value === undefined) {
718
+ return undefined;
719
+ }
720
+ // Passe au niveau suivant de l'objet/tableau
721
+ current = value;
722
+ }
723
+ // Retourne la valeur finale trouvée
724
+ return current;
725
+ }
726
+ /**
727
+ * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
728
+ * Mise à jour pour une meilleure gestion des types et des placeholders introuvables.
729
+ */
730
+ export async function substituteVariables(template, contextData, user) {
731
+ // 1. Retourner les types non substituables tels quels
732
+ if (template === null || (typeof template !== 'string' && typeof template !== 'object')) {
733
+ return template;
734
+ }
735
+
736
+ // 2. Gérer les tableaux de manière récursive
737
+ if (Array.isArray(template)) {
738
+ return Promise.all(template.map(item => substituteVariables(item, contextData, user)));
739
+ }
740
+
741
+ // 3. Gérer les objets de manière récursive
742
+ if (typeof template === 'object') {
743
+ const newObj = {};
744
+ for (const key in template) {
745
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
746
+ newObj[key] = await substituteVariables(template[key], contextData, user);
747
+ }
748
+ }
749
+ return newObj;
750
+ }
751
+
752
+ // --- À partir d'ici, nous savons que `template` est une chaîne de caractères ---
753
+
754
+ // 4. Construire le contexte complet pour la substitution
755
+ const dbCollection = getCollectionForUser(user);
756
+ const userEnvVars = await dbCollection.find({ _model: 'env', _user: user.username }).toArray();
757
+ const userEnv = userEnvVars.reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {});
758
+
759
+ // `contextToSearch` contient toutes les données disponibles à sa racine
760
+ const contextToSearch = { ...contextData, env: userEnv };
761
+
762
+ // 5. Logique de résolution de valeur unifiée
763
+ const findValue = (key) => {
764
+ let value;
765
+ let path = key.trim();
766
+
767
+ // Gérer les raccourcis : {triggerData.field} -> {context.triggerData.field}
768
+ if (path.startsWith('triggerData.')) {
769
+ path = 'context.' + path;
770
+ }
771
+ // Note : Pas de gestion spéciale pour {env.*} car `env` est déjà à la racine de contextToSearch.
772
+
773
+ // Gérer les valeurs dynamiques spéciales
774
+ if (path === 'now') {
775
+ value = new Date().toISOString();
776
+ } else if (path === 'randomUUID') {
777
+ // Suppose que 'crypto' est disponible dans le scope (ex: Node.js >= 19 ou avec import 'node:crypto')
778
+ value = crypto.randomUUID();
779
+ } else {
780
+ // Chercher le chemin dans l'objet de contexte entier
781
+ value = getNestedValue(contextToSearch, path);
782
+ }
783
+ return value;
784
+ };
785
+
786
+ // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
787
+ // Ceci préserve le type de donnée original (nombre, booléen, objet, etc.).
788
+ const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
789
+ if (singlePlaceholderMatch) {
790
+ const key = singlePlaceholderMatch[1];
791
+ const value = findValue(key);
792
+ // Si une valeur est trouvée, la retourner directement. Sinon, retourner le template original.
793
+ return value !== undefined ? value : template;
794
+ }
795
+
796
+ // CAS B : La chaîne est complexe (ex: "Produit: {context.triggerData.product.name}!")
797
+ // Le résultat sera toujours une chaîne de caractères.
798
+ const placeholderRegex = /\{([^}]+)\}/g;
799
+ return template.replace(placeholderRegex, (match, key) => {
800
+ const value = findValue(key);
801
+
802
+ if (value !== undefined) {
803
+ if (value === null) return 'null'; // Convertir explicitement null en chaîne 'null'
804
+ if (typeof value === 'object') return JSON.stringify(value); // Convertir les objets/tableaux en JSON
805
+ return String(value); // S'assurer que les autres types sont convertis en chaîne
806
+ }
807
+
808
+ return match; // Placeholder non trouvé, le laisser tel quel
809
+ });
810
+ }
811
+
812
+ /**
813
+ * Déclenche l'instanciation d'un workflowRun si les conditions sont remplies.
814
+ * Vérifie le type d'événement et le filtre de données du déclencheur.
815
+ * Crée un document 'workflowRun' pour une exécution asynchrone ultérieure.
816
+ *
817
+ * @param {object} triggerData - La donnée qui a déclenché le(s) workflow(s) (peut être un document de données ou un document de modèle).
818
+ * @param {object} user - L'utilisateur associé.
819
+ * @param {'DataAdded' | 'DataEdited' | 'DataDeleted' | 'ModelAdded' | 'ModelEdited' | 'ModelDeleted'} eventType - Le type d'événement.
820
+ */
821
+ export async function triggerWorkflows(triggerData, user, eventType) {
822
+ const dataId = eventType.startsWith('Model') ? null : triggerData._id;
823
+
824
+ const trigger = async (triggerData, user, eventType) => {
825
+
826
+ // Basic validation
827
+ if (!triggerData || !user || !eventType) {
828
+ console.warn("triggerWorkflows: Appel invalide - triggerData, user, ou eventType manquant.", { hasTriggerData: !!triggerData, hasUser: !!user, eventType });
829
+ return;
830
+ }
831
+ // Determine the model name and data ID based on the event type
832
+ const targetModelName = eventType.startsWith('Model') ? triggerData.name : triggerData._model;
833
+ const dataId = eventType.startsWith('Model') ? null : triggerData._id; // ID only relevant for data events
834
+
835
+ if (!targetModelName) {
836
+ console.warn(`triggerWorkflows: Impossible de déterminer le nom du modèle pour l'événement ${eventType}.`, triggerData);
837
+ return;
838
+ }
839
+
840
+ console.log(`[Workflow Trigger] Event: ${eventType}, Model: ${targetModelName}${dataId ? `, Data ID: ${dataId}` : ''}, User: ${user.username}`);
841
+
842
+ try {
843
+ const dbCollection = getCollectionForUser(user); // Collection des données utilisateur
844
+
845
+ // 1. Trouver les WorkflowTriggers pertinents
846
+ const workflowTriggers = await dbCollection.find({
847
+ _model: 'workflowTrigger',
848
+ targetModel: targetModelName,
849
+ isActive: true,
850
+ onEvent: eventType, // Assurez-vous que le champ s'appelle bien 'onEvent' dans votre modèle
851
+ $or: [{_user: user._user}, {_user: user.username}]
852
+ }).toArray();
853
+
854
+ if (workflowTriggers.length === 0) {
855
+ console.debug(`[Workflow Trigger] Aucun déclencheur actif trouvé pour ${targetModelName} / ${eventType}.`);
856
+ return;
857
+ }
858
+ console.debug(`[Workflow Trigger] Trouvé ${workflowTriggers.length} déclencheur(s) potentiel(s) pour ${targetModelName} / ${eventType}.`);
859
+
860
+ // 2. Pour chaque déclencheur trouvé, vérifier le filtre de données et créer un workflowRun
861
+ for (const trigger of workflowTriggers) {
862
+ console.debug(`[Workflow Trigger] Vérification du déclencheur ${trigger._id} (${trigger.name || 'Sans nom'})...`);
863
+
864
+ // 3. Vérifier le filtre de données (dataFilter) si applicable
865
+ if (eventType.startsWith('Data') && trigger.dataFilter && dataId) {
866
+ let dataFilterCondition = null;
867
+ try {
868
+ // dataFilter est supposé être stocké comme un objet (ou une string JSON valide)
869
+ if (typeof trigger.dataFilter === 'string') {
870
+ dataFilterCondition = JSON.parse(trigger.dataFilter);
871
+ } else if (typeof trigger.dataFilter === 'object' && trigger.dataFilter !== null) {
872
+ dataFilterCondition = trigger.dataFilter;
873
+ }
874
+ } catch (parseError) {
875
+ console.error(`[Workflow Trigger] Erreur de parsing JSON pour dataFilter du trigger ${trigger._id}:`, parseError);
876
+ continue; // Passer au trigger suivant si le filtre est invalide
877
+ }
878
+
879
+ try {
880
+ const finalFilter = {
881
+ '$and': [
882
+ dataFilterCondition // Applique la condition du trigger
883
+ ]
884
+ };
885
+
886
+ console.debug(`[Workflow Trigger] Vérification dataFilter pour trigger ${trigger._id} avec filtre combiné:`, JSON.stringify(finalFilter));
887
+
888
+ // Exécuter la vérification dans la base de données
889
+ // Utilisation de countDocuments pour une vérification rapide
890
+ const matchCount = await searchData({ user, query: { model: targetModelName, filter: finalFilter, limit: 1 } });
891
+
892
+ if (!matchCount.count) {
893
+ console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter non satisfait par la donnée ${dataId}. WorkflowRun non créé.`);
894
+ continue; // Passer au trigger suivant
895
+ } else {
896
+ console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter satisfait par la donnée ${dataId}.`);
897
+ }
898
+ } catch (filterError) {
899
+ console.error(`[Workflow Trigger] Erreur lors de la conversion ou de l'exécution du dataFilter pour le trigger ${trigger._id}:`, filterError);
900
+ continue; // Ne pas créer en cas d'erreur de filtre
901
+ }
902
+ } // Fin de la vérification dataFilter
903
+
904
+ // 4. Si les filtres (eventType, dataFilter) sont passés, créer l'instance workflowRun
905
+ if (!trigger.workflow || !isObjectId(trigger.workflow)) {
906
+ console.warn(`[Workflow Trigger] Trigger ${trigger._id} n'a pas de workflow valide associé.`);
907
+ continue;
908
+ }
909
+
910
+ // a. Récupérer la définition du Workflow (juste pour vérifier qu'il existe)
911
+ const workflowDefinition = await dbCollection.findOne({
912
+ _id: new ObjectId(trigger.workflow),
913
+ _model: 'workflow',
914
+ $or: [{_user: user._user}, {_user: user.username}]
915
+ });
916
+
917
+ if (!workflowDefinition) {
918
+ console.warn(`[Workflow Trigger] Workflow ${trigger.workflow} associé au trigger ${trigger._id} non trouvé.`);
919
+ continue;
920
+ }
921
+
922
+ // b. Créer le document workflowRun
923
+ const workflowRunData = {
924
+ _model: 'workflowRun',
925
+ _user: user._user || user.username,
926
+ workflow: workflowDefinition._id, // Référence au workflow parent
927
+ contextData: { // Contexte initial
928
+ triggerDataModel: targetModelName,// Modèle de la donnée déclencheuse
929
+ triggerData: triggerData // Inclure la donnée déclencheuse
930
+ // Vous pourriez ajouter d'autres infos ici si nécessaire
931
+ },
932
+ status: 'pending', // Statut initial
933
+ owner: null,
934
+ startedAt: new Date()
935
+ };
936
+
937
+ try {
938
+ const insertResult = await dbCollection.insertOne(workflowRunData);
939
+ if (insertResult.insertedId) {
940
+ console.info(`[Workflow Trigger] WorkflowRun ${insertResult.insertedId} créé pour le workflow ${workflowDefinition.name} (ID: ${workflowDefinition._id}) déclenché par ${trigger._id}.`);
941
+
942
+ await workflowModule.processWorkflowRun(insertResult.insertedId, user);
943
+ } else {
944
+ console.error(`[Workflow Trigger] Échec de la création du WorkflowRun pour le workflow ${workflowDefinition._id} (Trigger: ${trigger._id}).`);
945
+ }
946
+ } catch (insertError) {
947
+ console.error(`[Workflow Trigger] Erreur lors de l'insertion du WorkflowRun pour le workflow ${workflowDefinition._id} (Trigger: ${trigger._id}):`, insertError);
948
+ }
949
+
950
+ } // Fin de la boucle des triggers
951
+
952
+ } catch (error) {
953
+ console.error(`[Workflow Trigger] Erreur générale dans triggerWorkflows pour ${targetModelName}${dataId ? ` ID: ${dataId}` : ''} (Event: ${eventType}):`, error);
954
+ }
955
+ }
956
+
957
+ return new Promise((resolve) => setTimeout(async () => {
958
+ await trigger(triggerData, user, eventType);
959
+ resolve();
960
+ }, 0)
961
+ );
962
+ }
963
+
964
+ /**
965
+ * Processes a workflowRun instance step-by-step.
966
+ * Fetches the run, evaluates conditions, executes actions, and transitions
967
+ * to the next step based on success or failure, updating the workflowRun status.
968
+ *
969
+ * @param {string|ObjectId} workflowRunId - The ID of the workflowRun to process.
970
+ * @param {object} user - The user context for database access.
971
+ * @returns {Promise<void>}
972
+ */
973
+ // C:/Dev/hackersonline-engine/server/src/modules/workflow.js
974
+
975
+ export async function processWorkflowRun(workflowRunId, user) {
976
+ const dbCollection = getCollectionForUser(user);
977
+ const runId = typeof workflowRunId === 'string' ? new ObjectId(workflowRunId) : workflowRunId;
978
+
979
+ logger.info(`[processWorkflowRun] Starting processing for workflowRun ID: ${runId}`);
980
+
981
+ let currentRunState;
982
+ let contextData = {};
983
+ let stepExecutionsCount = {};
984
+
985
+ try {
986
+ currentRunState = await dbCollection.findOne({ _id: runId, _model: 'workflowRun' });
987
+
988
+ if (!currentRunState) {
989
+ logger.error(`[processWorkflowRun] WorkflowRun ID: ${runId} not found.`);
990
+ return;
991
+ }
992
+
993
+ stepExecutionsCount = currentRunState.stepExecutionsCount || {};
994
+ if (['completed', 'failed', 'cancelled'].includes(currentRunState.status)) {
995
+ logger.info(`[processWorkflowRun] WorkflowRun ID: ${runId} is already in a terminal state (${currentRunState.status}). Skipping.`);
996
+ return;
997
+ }
998
+
999
+ const logError = async (error) => {
1000
+ logger.error(error);
1001
+ await dbCollection.updateOne(
1002
+ { _id: runId },
1003
+ { $set: { status: 'failed', error, completedAt: new Date(), stepExecutionsCount } }
1004
+ );
1005
+ };
1006
+
1007
+ const workflowDefinition = await dbCollection.findOne({ _id: new ObjectId(currentRunState.workflow), _model: 'workflow' });
1008
+ if (!workflowDefinition) {
1009
+ return await logError(`Workflow definition ID: ${currentRunState.workflow} not found.`);
1010
+ }
1011
+
1012
+ contextData = currentRunState.contextData || {};
1013
+ let currentStepId = currentRunState.currentStep || workflowDefinition.startStep;
1014
+
1015
+ if (!currentStepId || !isObjectId(currentStepId)) {
1016
+ const finalStatus = workflowDefinition.startStep ? 'failed' : 'completed';
1017
+ const errorMessage = workflowDefinition.startStep ? 'No valid starting step defined in workflow or run state.' : null;
1018
+ await dbCollection.updateOne(
1019
+ { _id: runId },
1020
+ { $set: { status: finalStatus, error: errorMessage, completedAt: new Date(), currentStep: null, stepExecutionsCount } }
1021
+ );
1022
+ return;
1023
+ }
1024
+
1025
+ let stepCount = 0;
1026
+ while (currentStepId) {
1027
+ if (stepCount++ >= maxWorkflowSteps) {
1028
+ return await logError(`Maximum workflow step executions exceeded (${maxWorkflowSteps} max).`);
1029
+ }
1030
+
1031
+ const execCount = (stepExecutionsCount[currentStepId] || 0) + 1;
1032
+ if (execCount > maxExecutionsByStep) {
1033
+ return await logError(`Maximum executions (${maxExecutionsByStep}) exceeded for step ${currentStepId}.`);
1034
+ }
1035
+ stepExecutionsCount[currentStepId] = execCount;
1036
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Current Step ID: ${currentStepId}`);
1037
+
1038
+ const currentStepDef = await dbCollection.findOne({ _id: new ObjectId(currentStepId), _model: 'workflowStep' });
1039
+ if (!currentStepDef) {
1040
+ return await logError(`Step definition ID: ${currentStepId} not found.`);
1041
+ }
1042
+
1043
+ await dbCollection.updateOne(
1044
+ { _id: runId },
1045
+ { $set: { status: 'running', currentStep: currentStepId, contextData, stepExecutionsCount } }
1046
+ );
1047
+
1048
+ let stepSucceeded = true;
1049
+ let stepError = null;
1050
+ let conditionsMet = true;
1051
+
1052
+ try {
1053
+ // --- 7. Évaluation des conditions de l'étape ---
1054
+ if (currentStepDef.conditions && Object.keys(currentStepDef.conditions).length > 0) {
1055
+ const searchResult = await searchData({ user, query: { model: contextData.triggerDataModel, filter: currentStepDef.conditions, limit: 1 } });
1056
+ conditionsMet = searchResult && searchResult.count > 0;
1057
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Conditions evaluated. Found ${searchResult ? searchResult.count : 0} match(es). Result: ${conditionsMet}`);
1058
+ }
1059
+
1060
+ // --- 8. Exécution des actions si les conditions sont remplies ---
1061
+ if (conditionsMet) {
1062
+ if (currentStepDef.actions && currentStepDef.actions.length > 0) {
1063
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Executing ${currentStepDef.actions.length} action(s)...`);
1064
+ for (const actionId of currentStepDef.actions) {
1065
+ if (!isObjectId(actionId)) continue;
1066
+ const actionDef = await dbCollection.findOne({ _id: new ObjectId(actionId), _model: 'workflowAction' });
1067
+ if (!actionDef) return await logError(`Action definition ${actionId} not found.`);
1068
+ const actionResult = await workflowModule.executeStepAction(actionDef, contextData, user, dbCollection);
1069
+ if (!actionResult.success) {
1070
+ stepSucceeded = false;
1071
+ stepError = actionResult.message || `Action ${actionDef.name || actionId} failed.`;
1072
+ break;
1073
+ }
1074
+ if (actionResult.updatedContext) {
1075
+ contextData = { ...contextData, ...actionResult.updatedContext };
1076
+ }
1077
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}, Action ID: ${actionId}: Executed successfully.`);
1078
+ }
1079
+ }
1080
+ } else {
1081
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Conditions not met. Skipping actions.`);
1082
+ }
1083
+ } catch (error) {
1084
+ logger.error(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Error during condition/action execution: ${error.message}`);
1085
+ stepSucceeded = false;
1086
+ stepError = error.message;
1087
+ }
1088
+
1089
+ // --- 9. Détermination de la prochaine étape ---
1090
+ let nextStepId = null;
1091
+ let finalStatusForRun = null;
1092
+
1093
+ if (stepSucceeded && conditionsMet) {
1094
+ // CHEMIN SUCCÈS : Les conditions sont remplies et les actions ont réussi.
1095
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Step path succeeded.`);
1096
+ nextStepId = currentStepDef.onSuccessStep;
1097
+ if (currentStepDef.isTerminal || !nextStepId) {
1098
+ finalStatusForRun = 'completed';
1099
+ nextStepId = null;
1100
+ }
1101
+ } else {
1102
+ // CHEMIN ÉCHEC/BRANCHE : Une action a échoué OU les conditions n'ont pas été remplies.
1103
+ const reason = stepError ? `Action failed: ${stepError}` : 'Step conditions not met.';
1104
+ logger.warn(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Taking failure/branching path. Reason: ${reason}`);
1105
+ nextStepId = currentStepDef.onFailureStep;
1106
+
1107
+ if (!nextStepId || !isObjectId(nextStepId)) {
1108
+ // Fin du workflow. Le statut est 'failed' seulement si une vraie erreur s'est produite.
1109
+ finalStatusForRun = stepError ? 'failed' : 'completed';
1110
+ nextStepId = null;
1111
+ }
1112
+ }
1113
+
1114
+ // --- 10. Mise à jour de l'état de l'exécution ---
1115
+ currentStepId = nextStepId;
1116
+ const updatePayload = { contextData };
1117
+
1118
+ if (finalStatusForRun) {
1119
+ updatePayload.status = finalStatusForRun;
1120
+ updatePayload.completedAt = new Date();
1121
+ updatePayload.currentStep = null;
1122
+ if (finalStatusForRun === 'failed' && stepError) {
1123
+ updatePayload.error = stepError;
1124
+ }
1125
+ } else {
1126
+ updatePayload.currentStep = currentStepId;
1127
+ }
1128
+ await dbCollection.updateOne({ _id: runId }, { $set: updatePayload });
1129
+
1130
+ if(finalStatusForRun) {
1131
+ logger.info(`[processWorkflowRun] Finished processing for workflowRun ID: ${runId}. Final Status: ${finalStatusForRun}`);
1132
+ }
1133
+ }
1134
+ } catch (error) {
1135
+ logger.error(`[processWorkflowRun] Critical error during processing of workflowRun ID: ${runId}. Error: ${error.message}`, error.stack);
1136
+ await dbCollection.updateOne(
1137
+ { _id: runId, status: { $nin: ['completed', 'failed', 'cancelled'] } },
1138
+ { $set: { status: 'failed', error: `Critical error: ${error.message}`, completedAt: new Date(), stepExecutionsCount } }
1139
+ );
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Exécute une action de génération de contenu par IA ('GenerateAIContent').
1144
+ * Récupère la clé API (priorité à l'environnement de l'utilisateur), initialise un client LangChain,
1145
+ * formate un prompt avec les données du contexte, appelle le LLM, et retourne le résultat
1146
+ * pour l'ajouter au contexte du workflow.
1147
+ *
1148
+ * @param {object} action - La définition de l'action depuis le workflow.
1149
+ * @param {object} context - Le contexte d'exécution actuel du workflow.
1150
+ * @param {object} user - L'utilisateur qui exécute le workflow.
1151
+ * @returns {Promise<{success: boolean, updatedContext?: object, message?: string}>}
1152
+ */
1153
+ async function executeGenerateAIContentAction(action, context, user) {
1154
+ const { aiProvider, aiModel, prompt } = action;
1155
+
1156
+ // 1. Récupérer la clé API (Environnement de l'utilisateur > Environnement de la machine)
1157
+ let apiKey;
1158
+
1159
+ const providers = {
1160
+ "OpenAI" : "OPENAI_API_KEY",
1161
+ "Google": "GOOGLE_API_KEY",
1162
+ }
1163
+ const envKeyName = providers[aiProvider];
1164
+ if( !envKeyName ) {
1165
+ return {success: false, message: i18n.t('aiContent.env', `Clé API pour ${aiProvider} (${envKeyName}) non trouvée dans l'environnement de l'utilisateur.`)};
1166
+ }
1167
+
1168
+ // Cherche d'abord dans les variables d'environnement de l'utilisateur
1169
+ const envCollection = getCollectionForUser(user);
1170
+ const userEnvVar = await envCollection.findOne({ _model: 'env', name: envKeyName, _user: user.username });
1171
+
1172
+ if (userEnvVar && userEnvVar.value) {
1173
+ apiKey = userEnvVar.value;
1174
+ logger.debug(`[AI Action] Utilisation de la clé API de l'environnement de l'utilisateur pour ${aiProvider}.`);
1175
+ } else {
1176
+ apiKey = process.env[envKeyName];
1177
+ logger.debug(`[AI Action] Utilisation de la clé API de l'environnement de la machine pour ${aiProvider}.`);
1178
+ }
1179
+
1180
+ if (!apiKey) {
1181
+ const message = `Clé API pour ${aiProvider} (${envKeyName}) non trouvée dans l'environnement de l'utilisateur ou de la machine.`;
1182
+ logger.error(`[AI Action] ${message}`);
1183
+ return { success: false, message };
1184
+ }
1185
+
1186
+ // 2. Initialiser le client LLM avec LangChain
1187
+ let llm;
1188
+ try {
1189
+ switch (aiProvider) {
1190
+ case 'OpenAI':
1191
+ llm = new ChatOpenAI({ apiKey, modelName: aiModel, temperature: 0.7 });
1192
+ break;
1193
+ case 'GoogleGemini':
1194
+ llm = new ChatGoogleGenerativeAI({ apiKey, modelName: aiModel, temperature: 0.7 });
1195
+ break;
1196
+ default:
1197
+ throw new Error(`Fournisseur IA non supporté : ${aiProvider}`);
1198
+ }
1199
+ } catch (initError) {
1200
+ const message = `Échec de l'initialisation du client IA pour ${aiProvider}: ${initError.message}`;
1201
+ logger.error(`[AI Action] ${message}`);
1202
+ return { success: false, message };
1203
+ }
1204
+
1205
+ try {
1206
+ const substitutedPrompt = await substituteVariables(prompt, context, user);
1207
+ // 3. Créer le "Prompt Template"
1208
+ // LangChain gère la substitution des variables comme {triggerData.name}
1209
+ const realPrompt = ChatPromptTemplate.fromTemplate(substitutedPrompt);
1210
+
1211
+ // 4. Créer la chaîne de traitement (Prompt + Modèle)
1212
+ const chain = realPrompt.pipe(llm);
1213
+
1214
+ // 5. Invoquer la chaîne avec le contexte complet
1215
+ // LangChain remplacera automatiquement les placeholders dans le prompt.
1216
+ logger.debug(`[AI Action] Invocation de l'IA avec le modèle ${aiModel}.`);
1217
+ const response = await chain.invoke(context);
1218
+
1219
+ // 6. Préparer le résultat pour le fusionner dans le contexte du workflow
1220
+ const llmOutput = response.content;
1221
+ const outputVariable = 'aiContent';
1222
+ const updatedContext = {
1223
+ [outputVariable]: llmOutput
1224
+ };
1225
+
1226
+ logger.info(`[AI Action] Contenu généré avec succès et stocké dans la variable de contexte '${outputVariable}'.`);
1227
+
1228
+ return {
1229
+ success: true,
1230
+ updatedContext // Cet objet sera fusionné au contexte principal par le moteur de workflow
1231
+ };
1232
+
1233
+ } catch (llmError) {
1234
+ const message = `Erreur durant la génération de contenu IA avec ${aiProvider}: ${llmError.message}`;
1235
+ logger.error(`[AI Action] ${message}`, llmError.stack);
1236
+ return { success: false, message };
1237
+ }
1238
+ }
1239
+
1240
+
1241
+ /**
1242
+ * Gère l'action d'envoi d'email d'un workflow.
1243
+ * @param {object} action - L'objet workflowAction.
1244
+ * @param {object} triggerData - Les données qui ont déclenché le workflow.
1245
+ * @param {object} user - L'utilisateur propriétaire du workflow.
1246
+ */
1247
+ async function handleSendEmailAction(action, triggerData, user) {
1248
+
1249
+ logger.info(`[Workflow] Exécution de l'action sendEmail pour l'utilisateur ${user.username}.`);
1250
+
1251
+ // 1. Récupérer la configuration SMTP depuis le modèle 'env' de l'utilisateur
1252
+ const envVars = await searchData({
1253
+ user,
1254
+ query: { model: 'env', limit: 100 } // Limite raisonnable pour les variables d'env
1255
+ });
1256
+
1257
+ if (!envVars.data || envVars.data.length === 0) {
1258
+ throw new Error("Aucune variable d'environnement (modèle 'env') trouvée pour la configuration SMTP.");
1259
+ }
1260
+
1261
+ const smtpConfig = envVars.data.reduce((acc, variable) => {
1262
+ if (['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM'].includes(variable.name)) {
1263
+ acc[variable.name.replace('SMTP_', '').toLowerCase()] = variable.value;
1264
+ }
1265
+ return acc;
1266
+ }, {});
1267
+
1268
+ // 2. Extraire la configuration de l'action et résoudre les placeholders
1269
+ const { emailRecipients, emailSubject, emailContent } = action;
1270
+ if (!emailRecipients || !emailSubject || !emailContent) {
1271
+ throw new Error("SendEmail incomplete (emailRecipients, emailSubject, emailContent are needed).");
1272
+ }
1273
+
1274
+ try {
1275
+ const context = { data: triggerData, user }; // Contexte pour la résolution des placeholders
1276
+
1277
+ const rto = (await Promise.all(emailRecipients.map(r => substituteVariables(r, context, user))));
1278
+ const rsubject = await substituteVariables(emailSubject, context, user);
1279
+ const rbody = await substituteVariables(emailContent, context, user);
1280
+
1281
+ // 3. Préparer les données pour sendEmail
1282
+ const emailData = {
1283
+ title: rsubject,
1284
+ content: rbody,
1285
+ };
1286
+
1287
+ await sendEmail(rto, emailData, smtpConfig, user.lang);
1288
+ logger.info(`[Workflow] Action sendEmail terminée avec succès pour le destinataire: ${rto}`);
1289
+
1290
+ return { success: true, message: `Email sent to ${rto.join(', ')}` };
1291
+
1292
+ } catch (error) {
1293
+ logger.error(`[handleSendEmailAction] Erreur lors de l'envoi de l'email : ${error.message}`, error.stack);
1294
+ return { success: false, message: error.message };
1295
+ }
1296
+ }