data-primals-engine 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +1080 -212
  3. package/client/package.json +12 -6
  4. package/client/src/AssistantChat.jsx +1 -3
  5. package/client/src/DataLayout.jsx +19 -20
  6. package/client/src/DataTable.jsx +2 -2
  7. package/client/src/DocumentationPageLayout.scss +1 -1
  8. package/client/src/ViewSwitcher.jsx +1 -1
  9. package/client/vite.config.js +31 -30
  10. package/package.json +39 -23
  11. package/src/ai.jobs.js +135 -0
  12. package/src/constants.js +561 -545
  13. package/src/core.js +487 -477
  14. package/src/data.js +2 -0
  15. package/src/email.js +0 -2
  16. package/src/engine.js +50 -42
  17. package/src/filter.js +348 -343
  18. package/src/modules/assistant/assistant.js +782 -763
  19. package/src/modules/assistant/constants.js +23 -16
  20. package/src/modules/assistant/providers.js +77 -37
  21. package/src/modules/bucket.js +4 -0
  22. package/src/modules/data/data.cluster.js +191 -0
  23. package/src/modules/data/data.core.js +11 -8
  24. package/src/modules/data/data.js +13 -4
  25. package/src/modules/data/data.operations.js +186 -106
  26. package/src/modules/data/data.relations.js +1 -0
  27. package/src/modules/data/data.replication.js +83 -0
  28. package/src/modules/data/data.routes.js +2183 -1879
  29. package/src/modules/mongodb.js +76 -73
  30. package/src/modules/user.js +7 -1
  31. package/src/modules/worker-script-runner.js +97 -0
  32. package/src/modules/workflow.js +1953 -1815
  33. package/src/packs.js +5701 -5697
  34. package/src/providers.js +298 -297
  35. package/test/assistant.test.js +207 -206
  36. package/test/data.integration.test.js +1425 -1416
  37. package/test/import_export.integration.test.js +210 -210
  38. package/test/workflow.actions.integration.test.js +487 -475
  39. package/test/workflow.integration.test.js +332 -329
@@ -1,1816 +1,1954 @@
1
- import {getCollection, getCollectionForUser, isObjectId} from "./mongodb.js";
2
- import schedule from "node-schedule";
3
- import {ObjectId} from "mongodb";
4
-
5
- import ivm from 'isolated-vm';
6
-
7
- import {Logger} from "../gameObject.js";
8
- import {deleteData, getModel, insertData, patchData, searchData} from "./data/index.js";
9
- import { maxExecutionsByStep, maxWorkflowSteps } from "../constants.js";
10
- import {ChatPromptTemplate} from "@langchain/core/prompts";
11
- import i18n from "../../src/i18n.js";
12
- import {sendEmail} from "../email.js";
13
-
14
- import * as workflowModule from './workflow.js';
15
- import {isConditionMet} from "../filter.js";
16
- import { services } from '../services/index.js';
17
- import {getEnv, getSmtpConfig} from "./user.js";
18
- import { providers } from "./assistant/constants.js";
19
- import { getAIProvider } from "./assistant/providers.js";
20
- import {Config} from "../config.js";
21
- import {safeAssignObject} from "../core.js";
22
-
23
- /**
24
- * Récupère une valeur imbriquée dans un objet (ex: 'user.address.city').
25
- */
26
- const getNestedValue = (obj, path) => {
27
- if (!path || !obj) return undefined;
28
- return path.split('.').reduce((acc, part) => acc && acc[part] !== undefined ? acc[part] : undefined, obj);
29
- };
30
-
31
-
32
- /**
33
- * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
34
- * Version améliorée avec support des chemins complexes via resolvePathValue.
35
- */
36
- export async function substituteVariables(template, contextData, user) {
37
- // 1. Retourner les types non substituables tels quels
38
- if (template === null || (typeof template !== 'string' && typeof template !== 'object')) {
39
- return template;
40
- }
41
-
42
- // 2. Gérer les tableaux de manière récursive
43
- if (Array.isArray(template)) {
44
- return Promise.all(template.map(item => substituteVariables(item, contextData, user)));
45
- }
46
-
47
- // 3. Gérer les objets de manière récursive
48
- if (typeof template === 'object') {
49
- const newObj = {};
50
- for (const key in template) {
51
- if (Object.prototype.hasOwnProperty.call(template, key)) {
52
- const val = await substituteVariables(template[key], contextData, user);
53
- safeAssignObject(newObj, key, val);
54
- }
55
- }
56
- return newObj;
57
- }
58
-
59
- // --- À partir d'ici, nous savons que `template` est une chaîne de caractères ---
60
-
61
- // 4. Construire le contexte complet pour la substitution
62
- const dbCollection = await getCollectionForUser(user);
63
- const userEnvVars = await dbCollection.find({ _model: 'env', _user: user.username }).toArray();
64
- const userEnv = userEnvVars.reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {});
65
-
66
- // `contextToSearch` contient toutes les données disponibles à sa racine
67
- const contextToSearch = { ...contextData, env: userEnv };
68
-
69
- // 5. Logique de résolution de valeur améliorée avec resolvePathValue
70
- const findValue = async (key) => {
71
- let path = key.trim();
72
- if (path.startsWith('context.')) {
73
- path = path.substring('context.'.length);
74
- }
75
- if (path.endsWith('._id')) {
76
- const basePath = path.slice(0, -4);
77
- const value = await findValue(basePath);
78
- return value?._id?.toString(); // Convertit l'ObjectId en string
79
- }
80
-
81
- // Gérer les valeurs dynamiques spéciales
82
- if (path === 'now') {
83
- return new Date().toISOString();
84
- } else if (path === 'randomUUID') {
85
- return crypto.randomUUID();
86
- } else if( path === "baseUrl" ){
87
- return process.env.NODE_ENV === 'production' ? 'https://'+getHost()+'/' : 'http://localhost:/'+port;
88
- }
89
-
90
- // Détecter si le chemin est complexe (contient plus d'un point)
91
- if (path.split('.').length > 1) {
92
- try {
93
- // Essayer de résoudre le chemin avec resolvePathValue
94
- const [root, ...rest] = path.split('.');
95
- // On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
96
- if (contextToSearch[root]) {
97
- const resolvedValue = await resolvePathValue(
98
- rest.join('.'),
99
- contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
100
- user
101
- );
102
- if (resolvedValue !== undefined) {
103
- return resolvedValue;
104
- }
105
- }
106
- } catch (error) {
107
- console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
108
- // On continue avec la méthode normale si la résolution échoue
109
- }
110
- }
111
-
112
- // Fallback: chercher le chemin dans l'objet de contexte normal
113
- return getNestedValue(contextToSearch, path);
114
- };
115
-
116
- // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
117
- const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
118
- if (singlePlaceholderMatch) {
119
- const key = singlePlaceholderMatch[1];
120
- const value = await findValue(key);
121
-
122
- if (value === undefined) {
123
- return template; // Placeholder not found, return as is.
124
- }
125
-
126
- // If the resolved value is a string, it might contain more placeholders.
127
- // We recursively call substituteVariables on it, but only if it's different
128
- // from the original template to prevent infinite loops.
129
- if (typeof value === 'string' && value !== template) {
130
- return substituteVariables(value, contextData, user);
131
- }
132
-
133
- // For non-string values or if value is same as template, return the value.
134
- return value;
135
- }
136
-
137
- // CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
138
- const placeholderRegex = /\{([^}]+)\}/g;
139
- const placeholders = [...template.matchAll(placeholderRegex)];
140
-
141
- // Si aucun placeholder trouvé, retourner la chaîne telle quelle
142
- if (placeholders.length === 0) {
143
- return template;
144
- }
145
-
146
- // Remplacer chaque placeholder de manière asynchrone
147
- let result = template;
148
- for (const [match, key] of placeholders) {
149
- const value = await findValue(key);
150
- const replacement = value !== undefined
151
- ? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
152
- : match;
153
- result = result.replace(match, replacement);
154
- }
155
-
156
- return result;
157
- }
158
-
159
-
160
- let logger = null;
161
- export async function onInit(defaultEngine) {
162
- logger = defaultEngine.getComponent(Logger);
163
-
164
- await scheduleWorkflowTriggers();
165
- }
166
-
167
- /**
168
- * Déclenche un workflow par son nom et lui passe des données de contexte.
169
- * C'est la fonction clé à exposer aux endpoints pour lancer des processus métier.
170
- *
171
- * @param {string} name - Le nom du workflow à exécuter.
172
- * @param {object} data - Les données à injecter dans context.triggerData.
173
- * @param {object} user - L'objet utilisateur qui initie l'action.
174
- * @returns {Promise<{success: boolean, message?: string, runId?: ObjectId}>}
175
- */
176
- export async function runWorkflowByName(name, data, user) {
177
- if (!name) {
178
- return { success: false, message: "Workflow name is required." };
179
- }
180
-
181
- const dbCollection = await getCollectionForUser(user);
182
-
183
- // 1. Trouver la définition du workflow par son nom
184
- const workflowDefinition = await dbCollection.findOne({ _model: 'workflow', name });
185
-
186
- if (!workflowDefinition) {
187
- const msg = `Workflow with name "${name}" not found.`;
188
- logger.error(`[runWorkflowByName] ${msg}`);
189
- return { success: false, message: msg };
190
- }
191
-
192
- // 2. Créer le document workflowRun
193
- const workflowRunData = {
194
- _model: 'workflowRun',
195
- _user: user._user || user.username,
196
- workflow: workflowDefinition._id,
197
- contextData: { triggerData: data }, // Les données passées deviennent le triggerData
198
- status: 'pending',
199
- startedAt: new Date()
200
- };
201
-
202
- const insertResult = await dbCollection.insertOne(workflowRunData);
203
- logger.info(`[runWorkflowByName] Created workflowRun ${insertResult.insertedId} for workflow "${name}".`);
204
-
205
- // 3. Lancer le traitement de manière asynchrone
206
- await processWorkflowRun(insertResult.insertedId, user);
207
-
208
- return { success: true, runId: insertResult.insertedId };
209
- }
210
- /**
211
- * Exécute une fonction de manière sécurisée en s'assurant qu'une seule instance
212
- * s'exécute à la fois, grâce à un système de verrouillage distribué basé sur la base de données.
213
- * Cette fonction est atomique et conçue pour éviter les conditions de course.
214
- *
215
- * @param {string} jobId - Un identifiant unique pour la tâche (ex: 'workflowTrigger_monId').
216
- * @param {Function} jobFunction - La fonction asynchrone à exécuter si le verrou est acquis.
217
- * @param {number} [lockDurationMinutes=5] - La durée en minutes pendant laquelle le verrou est considéré comme valide.
218
- * @returns {Promise<void>}
219
- */
220
- export async function runScheduledJobWithDbLock(jobId, jobFunction, lockDurationMinutes = 5) {
221
- const jobsCollection = getCollection('job_locks');
222
- const now = new Date();
223
- const lockExpiresAt = new Date(now.getTime() + lockDurationMinutes * 60 * 1000);
224
- let lockAcquired = false; // Drapeau pour savoir si nous devons libérer le verrou
225
-
226
- // Le bloc try...finally garantit que la libération du verrou est tentée
227
- // même si la fonction jobFunction lève une exception.
228
- try {
229
- // --- PHASE 1: ACQUISITION DU VERROU (de manière atomique) ---
230
-
231
- // Tentative 1: Mettre à jour un verrou existant qui a expiré.
232
- // C'est le cas le plus courant après la première exécution.
233
- // L'opération `updateOne` est atomique.
234
- const updateResult = await jobsCollection.updateOne(
235
- {
236
- jobId: jobId,
237
- lockedUntil: { $lt: now } // Le verrou est disponible si sa date d'expiration est dans le passé
238
- },
239
- {
240
- $set: { lockedUntil: lockExpiresAt, lastStarted: now },
241
- $inc: { runCount: 1 }
242
- }
243
- );
244
-
245
- if (updateResult.modifiedCount === 1) {
246
- // Succès : nous avons mis à jour le verrou expiré et l'avons acquis.
247
- lockAcquired = true;
248
- logger.info(`[Lock] Verrou existant acquis pour la tâche ${jobId}.`);
249
- } else {
250
- // Si aucun document n'a été modifié, soit le verrou n'existe pas,
251
- // soit il est actuellement détenu par un autre processus.
252
- // Tentative 2: Insérer un nouveau document de verrou.
253
- // Cette opération échouera avec une erreur de clé dupliquée (code 11000)
254
- // si un autre processus a réussi à créer le verrou entre-temps.
255
- try {
256
- await jobsCollection.insertOne({
257
- jobId: jobId,
258
- lockedUntil: lockExpiresAt,
259
- lastStarted: now,
260
- runCount: 1
261
- });
262
- // Succès : nous avons créé un nouveau verrou et l'avons acquis.
263
- lockAcquired = true;
264
- logger.info(`[Lock] Nouveau verrou créé pour la tâche ${jobId}.`);
265
- } catch (insertError) {
266
- if (insertError.code === 11000) {
267
- // Comportement attendu : un autre processus a acquis le verrou.
268
- // Ce n'est pas une erreur, on saute simplement l'exécution.
269
- logger.info(`[Lock] Impossible d'acquérir le verrou pour ${jobId}, un autre processus le dent. Exécution ignorée.`);
270
- } else {
271
- // Une erreur de base de données inattendue s'est produite.
272
- throw insertError;
273
- }
274
- }
275
- }
276
-
277
- // --- PHASE 2: EXÉCUTION DE LA TÂCHE ---
278
- if (lockAcquired) {
279
- logger.info(`[Lock] Exécution de la fonction pour la tâche ${jobId}...`);
280
- await jobFunction();
281
- logger.info(`[Lock] La fonction pour la tâche ${jobId} s'est terminée.`);
282
- }
283
-
284
- } catch (error) {
285
- // Capture les erreurs de la `jobFunction` ou les erreurs inattendues de la base de données.
286
- logger.error(`Erreur durant l'exécution de la tâche verrouillée ${jobId}:`, error);
287
- } finally {
288
- // --- PHASE 3: LIBÉRATION DU VERROU ---
289
- if (lockAcquired) {
290
- try {
291
- // On libère le verrou en mettant sa date d'expiration dans le passé,
292
- // le rendant immédiatement disponible pour la prochaine exécution.
293
- await jobsCollection.updateOne(
294
- { jobId: jobId },
295
- { $set: { lockedUntil: new Date(0) } }
296
- );
297
- logger.info(`[Lock] Verrou libéré pour la tâche ${jobId}.`);
298
- } catch (releaseError) {
299
- // Il est crucial de logger cette erreur, car un verrou non libéré peut bloquer les futures exécutions.
300
- logger.error(`CRITIQUE: Échec de la libération du verrou pour la tâche ${jobId}. Une intervention manuelle peut être nécessaire.`, releaseError);
301
- }
302
- }
303
- }
304
- }
305
-
306
-
307
- /**
308
- * Planifie l'exécution des workflows déclenchés par une cronExpression.
309
- * Utilise runScheduledJobWithDbLock pour assurer l'exécution unique à travers plusieurs instances.
310
- */
311
- export async function scheduleWorkflowTriggers() {
312
- logger.info('Starting scheduling of workflow triggers...');
313
- try {
314
- const datasCollection = getCollection('datas'); // Ou la collection appropriée pour les workflows
315
-
316
- // Trouver tous les workflows actifs avec une cronExpression définie
317
- const workflowsToSchedule = await datasCollection.find({
318
- _model: 'workflowTrigger',
319
- cronExpression: { $exists: true, $ne: "" }
320
- // Ajoutez d'autres conditions si nécessaire (ex: active: true)
321
- }).toArray();
322
-
323
- console.log(`Found ${workflowsToSchedule.length} workflow triggers with cron expressions to schedule.`);
324
-
325
- for (const workflow of workflowsToSchedule) {
326
- const jobId = `workflowTrigger_${workflow._id}`; // ID unique pour le verrou du job
327
- const cronExpression = workflow.cronExpression;
328
- if( !cronExpression )
329
- continue;
330
- // Planifier la tâche en utilisant node-schedule
331
- schedule.scheduleJob(cronExpression, async () => {
332
- console.log(`Cron triggered for job ${jobId}. Attempting to run with lock...`);
333
-
334
- // Utiliser runScheduledJobWithDbLock pour exécuter la tâche
335
- await runScheduledJobWithDbLock(
336
- jobId,
337
- async () => {
338
- // --- Début de la logique spécifique au workflow ---
339
- // C'est ici que vous mettriez le code qui doit être exécuté
340
- // lorsque le workflow est déclenché par le cron.
341
- // Par exemple:
342
- console.log(`Executing task logic for workflow ${workflow.name} (ID: ${workflow._id})`);
343
-
344
- // Exemple:
345
- // const targetModel = workflow.targetModel;
346
- // const action = workflow.action;
347
- // await executeWorkflowAction(targetModel, action, workflow.parameters);
348
-
349
- // Simule une tâche asynchrone
350
- await new Promise(resolve => setTimeout(resolve, 2000));
351
-
352
- console.log(`Task logic completed for workflow ${workflow.name} (ID: ${workflow._id})`);
353
- // --- Fin de la logique spécifique au workflow ---
354
- },
355
- workflow.lockDurationMinutes || 5 // Utilise la durée du workflow ou une valeur par défaut
356
- );
357
- });
358
-
359
- }
360
-
361
- console.log('Finished scheduling workflow triggers.');
362
-
363
- } catch (error) {
364
- console.error('Error during scheduling of workflow triggers:', error);
365
- }
366
- }
367
-
368
- async function handleWaitAction(actionDef, contextData, user) {
369
- const { duration, durationUnit } = actionDef;
370
- if (!duration || !durationUnit) {
371
- return { success: false, message: "Wait action requires 'duration' and 'durationUnit'." };
372
- }
373
-
374
- // Retourne un statut spécial que le moteur de workflow comprendra
375
- return {
376
- success: true,
377
- status: 'paused', // Statut spécial
378
- duration,
379
- durationUnit,
380
- message: `Workflow will be paused for ${duration} ${durationUnit}.`
381
- };
382
- }
383
-
384
-
385
- export async function executeSafeJavascript(actionDef, context, user) {
386
- const code = actionDef.script;
387
- const collectedLogs = [];
388
- const isolate = new ivm.Isolate({ memoryLimit: 128 }); // 128MB memory limit
389
-
390
- try {
391
- const vmContext = await isolate.createContext();
392
- const jail = vmContext.global;
393
-
394
- const find = async (modelName, filter) => {
395
- const result = await searchData({ model: modelName, filter: JSON.parse(filter) }, user);
396
- return new ivm.ExternalCopy(result).copyInto();
397
- };
398
- const findOne = async (modelName, filter) => {
399
- const result = await searchData({ model: modelName, filter: JSON.parse(filter), limit: 1 }, user);
400
- return new ivm.ExternalCopy(result.data?.[0] || null).copyInto();
401
- };
402
-
403
- // 1. Build the sandboxed API methods
404
- await jail.set('_workflow_run', new ivm.Reference(async (name, contextData) => {
405
- const result = await runWorkflowByName(name, JSON.parse(contextData), user);
406
- return new ivm.ExternalCopy(result).copyInto();
407
- }));
408
- await jail.set('_db_create', new ivm.Reference(async (modelName, dataObject) => {
409
- const result = await insertData(modelName, JSON.parse(dataObject), {}, user, false);
410
- if (result.success && result.insertedIds) {
411
- result.insertedIds = result.insertedIds.map(id => id.toString());
412
- }
413
- return new ivm.ExternalCopy(result).copyInto();
414
- }));
415
- await jail.set('_db_find', new ivm.Reference(find));
416
- await jail.set('_db_findOne', new ivm.Reference(findOne));
417
-
418
- await jail.set('_db_update', new ivm.Reference(async (modelName, filter, updateObject) => {
419
- const result = await patchData(modelName, JSON.parse(filter), JSON.parse(updateObject), {}, user, false);
420
- return new ivm.ExternalCopy(result).copyInto();
421
- }));
422
- await jail.set('_db_delete', new ivm.Reference(async (modelName, filter) => {
423
- const result = await deleteData(modelName, JSON.parse(filter), user, false);
424
- return new ivm.ExternalCopy(result).copyInto();
425
- }));
426
-
427
- const createLoggerMethod = (level) => {
428
- return (...args) => {
429
- const message = args.join(' ');
430
- collectedLogs.push({
431
- level,
432
- message,
433
- timestamp: new Date().toISOString()
434
- });
435
- logger.trace(level, '[VM Script]', message);
436
- };
437
- };
438
-
439
- await jail.set('_log_info', createLoggerMethod('info'));
440
- await jail.set('_log_warn', createLoggerMethod('warn'));
441
- await jail.set('_log_error', createLoggerMethod('error'));
442
- await jail.set('_env_get', new ivm.Reference(async (variableName) => {
443
- if (!variableName) return null;
444
- const result = await searchData({ model: 'env', filter: { name: variableName }, limit: 1 }, user);
445
- return new ivm.ExternalCopy(result.data?.[0]?.value || null).copyInto();
446
- }));
447
- await jail.set('_env_get_all', new ivm.Reference(async () => {
448
- const result = await getEnv(user);
449
- return new ivm.ExternalCopy(result).copyInto();
450
- }));
451
- await jail.set('_http_request', new ivm.Reference(async (method, url, optionsStr) => {
452
- try {
453
- const options = optionsStr ? JSON.parse(optionsStr) : {};
454
- const fetchOptions = {
455
- method: method.toUpperCase(),
456
- headers: options.headers || {},
457
- body: options.body ? (typeof options.body === 'object' ? JSON.stringify(options.body) : options.body) : undefined
458
- };
459
-
460
- const response = await fetch(url, fetchOptions);
461
- const responseBody = await response.json().catch(() => response.text());
462
-
463
- const result = { success: response.ok, status: response.status, body: responseBody };
464
- return new ivm.ExternalCopy(result).copyInto();
465
- } catch (error) {
466
- logger.error(`[VM http_request] Error: ${error.message}`);
467
- return new ivm.ExternalCopy({ success: false, message: error.message }).copyInto();
468
- }
469
- }));
470
-
471
- // Contexte sécurisé
472
- const safeContext = JSON.parse(JSON.stringify(context));
473
-
474
- await jail.set('context', new ivm.ExternalCopy(safeContext).copyInto());
475
-
476
- // Exécution
477
- const fullScript = `
478
- const normalizeArgs = args => args.map(arg => {
479
- if (typeof arg === 'object' && arg !== null) {
480
- return JSON.stringify(arg); // Convert objects to strings
481
- }
482
- return arg;
483
- });
484
- const db = {
485
- create: (...args) => _db_create.applySyncPromise(null, normalizeArgs(args)),
486
- find: (...args) => _db_find.applySyncPromise(null, normalizeArgs(args)),
487
- findOne: (...args) => _db_findOne.applySyncPromise(null, normalizeArgs(args)),
488
- update: (...args) => _db_update.applySyncPromise(null, normalizeArgs(args)),
489
- delete: (...args) => _db_delete.applySyncPromise(null, normalizeArgs(args))
490
- };
491
-
492
- const workflow = {
493
- run: (...args) => _workflow_run.applySyncPromise(null, normalizeArgs(args))
494
- };
495
-
496
- const logger = {
497
- info: _log_info,
498
- warn: _log_warn,
499
- error: _log_error
500
- };
501
-
502
- const env = {
503
- get: _env_get,
504
- getAll: _env_get_all
505
- };
506
-
507
- const http = {
508
- request: (...args) => _http_request.applySyncPromise(null, normalizeArgs(args))
509
- };
510
-
511
- (async function() {
512
- ${code}
513
- })();
514
- `;
515
-
516
- const TIMEOUT = 5000;
517
- const script = await isolate.compileScript(fullScript, { timeout: TIMEOUT });
518
- const result = await script.run(vmContext, {
519
- timeout: TIMEOUT,
520
- promise: true,
521
- copy: true // Copie automatique du résultat
522
- });
523
-
524
- // Vérifier si le script lui-même a signalé un échec.
525
- if (result && typeof result === 'object' && result.success === false) {
526
- const scriptMessage = result.message || 'Le script a signalé un échec sans message.';
527
- collectedLogs.push({
528
- level: 'warn',
529
- message: `Script reported failure: ${scriptMessage}`,
530
- timestamp: new Date().toISOString()
531
- });
532
- return {
533
- success: false,
534
- message: scriptMessage,
535
- logs: collectedLogs
536
- };
537
- }
538
-
539
- return { success: true, data: result, logs: collectedLogs, updatedContext: { result } };
540
- } catch (error) {
541
- const errorMessage = `Script execution failed: ${error.message}`;
542
- const finalErrorMessage = logger.trace('critical', `[VM Script] ${errorMessage}\n${error.stack}`);
543
- collectedLogs.push({
544
- level: 'critical',
545
- message: finalErrorMessage,
546
- timestamp: new Date().toISOString()
547
- });
548
- return { success: false, message: errorMessage, logs: collectedLogs };
549
- } finally {
550
- // 3. CRUCIAL: Dispose of the isolate to prevent memory leaks
551
- if (isolate && !isolate.isDisposed) {
552
- isolate.dispose();
553
- }
554
- }
555
- }
556
-
557
- /**
558
- * Handles the 'HttpRequest' workflow action.
559
- * Sends an HTTP request to a specified URL with substituted data using native fetch.
560
- *
561
- * @param {object} actionDef - The definition of the 'Webhook' action.
562
- * @param {object} contextData - The current workflow run context data.
563
- * @param {object} user - The user object (peut être utilisé pour l'authentification ou le logging).
564
- * @param {object} dbCollection - The MongoDB collection (moins pertinent ici, mais gardé pour la cohérence).
565
- * @returns {Promise<{success: boolean, message?: string, responseStatus?: number, responseBody?: any}>} - Result of the action.
566
- */
567
- async function handleHttpRequestAction(actionDef, contextData, user, dbCollection) {
568
- const { name: actionName, _id: actionId, url, method = 'POST', headers: headersTemplate, body: bodyTemplate } = actionDef;
569
-
570
- // 1. Basic Validation
571
- if (!url) {
572
- const msg = `[handleHttpRequestAction] Action ${actionName} (${actionId}): Missing 'url'.`;
573
- logger.error(msg);
574
- return { success: false, message: msg };
575
- }
576
-
577
- logger.info(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Executing webhook. Method: ${method}`);
578
-
579
- try {
580
- // 2. Substitute Variables
581
- const substitutedUrl = await substituteVariables(url, contextData, user);
582
- let substitutedHeadersString;
583
- let substitutedBodyString;
584
- let headersObject = {};
585
- let bodyObject = null;
586
-
587
- // Substitute Headers (JSON string or object)
588
- if (headersTemplate) {
589
- if (typeof headersTemplate === 'string') {
590
- substitutedHeadersString = await substituteVariables(headersTemplate, contextData, user);
591
- } else if (typeof headersTemplate === 'object') {
592
- headersObject = await substituteVariables(headersTemplate, contextData, user);
593
- } else {
594
- logger.warn(`[handleHttpRequestAction] Action ${actionName} (${actionId}): 'headers' has an invalid type (${typeof headersTemplate}). Ignoring.`);
595
- }
596
- }
597
-
598
- // Substitute Body (JSON string or object) - only relevant for methods like POST, PUT, PATCH
599
- if (bodyTemplate && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
600
- if (typeof bodyTemplate === 'string') {
601
- substitutedBodyString = await substituteVariables(bodyTemplate, contextData, user);
602
- } else if (typeof bodyTemplate === 'object') {
603
- bodyObject = await substituteVariables(bodyTemplate, contextData, user);
604
- } else {
605
- logger.warn(`[handleHttpRequestAction] Action ${actionName} (${actionId}): 'body' has an invalid type (${typeof bodyTemplate}). Ignoring.`);
606
- }
607
- }
608
-
609
- // 3. Parse substituted JSON strings
610
- if (substitutedHeadersString) {
611
- try {
612
- headersObject = JSON.parse(substitutedHeadersString);
613
- if (typeof headersObject !== 'object' || headersObject === null) {
614
- throw new Error("Parsed headers is not a valid object.");
615
- }
616
- } catch (parseError) {
617
- logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Failed to parse substituted 'headers' JSON. Error: ${parseError.message}. Using default headers. Substituted string: ${substitutedHeadersString}`);
618
- headersObject = { 'Content-Type': 'application/json' }; // Fallback
619
- }
620
- }
621
- // Ensure Content-Type if body is present and headers don't specify it
622
- if (bodyObject !== null || substitutedBodyString) {
623
- if (!headersObject['Content-Type'] && !headersObject['content-type']) {
624
- headersObject['Content-Type'] = 'application/json';
625
- }
626
- }
627
-
628
-
629
- if (substitutedBodyString) {
630
- try {
631
- // Try parsing first, maybe it's valid JSON already
632
- bodyObject = JSON.parse(substitutedBodyString);
633
- } catch (parseError) {
634
- // If parsing fails, treat it as a plain string body
635
- bodyObject = substitutedBodyString;
636
- // Adjust Content-Type if it was assumed to be JSON
637
- if (headersObject['Content-Type'] === 'application/json') {
638
- headersObject['Content-Type'] = 'text/plain';
639
- }
640
- }
641
- }
642
-
643
- // 4. Prepare Fetch Options
644
- const fetchOptions = {
645
- method: method.toUpperCase(),
646
- headers: headersObject // Native fetch accepts an object directly
647
- };
648
-
649
- if (bodyObject !== null && ['POST', 'PUT', 'PATCH'].includes(fetchOptions.method)) {
650
- // Stringify if it's an object and content type is JSON, otherwise use as is
651
- if (typeof bodyObject === 'object' && headersObject['Content-Type'] === 'application/json') {
652
- fetchOptions.body = JSON.stringify(bodyObject);
653
- } else {
654
- fetchOptions.body = bodyObject; // Use string directly
655
- }
656
- }
657
-
658
- // 5. Execute Fetch Request using native fetch
659
- logger.info(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Calling URL: ${substitutedUrl}`);
660
- const response = await fetch(substitutedUrl, fetchOptions); // Utilisation de fetch natif
661
-
662
- // 6. Process Response
663
- let responseBody;
664
- const contentType = response.headers.get('content-type');
665
- try {
666
- if (contentType && contentType.includes('application/json')) {
667
- responseBody = await response.json();
668
- } else {
669
- responseBody = await response.text();
670
- }
671
- } catch (responseParseError) {
672
- logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Failed to parse response body. Error: ${responseParseError.message}`);
673
- // Try reading as text again in case of error during json parsing
674
- try {
675
- responseBody = await response.text();
676
- } catch (textError) {
677
- responseBody = "[Could not parse response body]";
678
- }
679
- }
680
-
681
- logger.info(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Received response. Status: ${response.status}`);
682
-
683
- // 7. Return Result
684
- if (response.ok) { // Status code 200-299
685
- return {
686
- success: true,
687
- message: `Webhook executed successfully. Status: ${response.status}`,
688
- responseStatus: response.status,
689
- responseBody: responseBody,
690
- updatedContext: { httpResponse: responseBody }
691
- };
692
- } else {
693
- // Handle non-successful responses (4xx, 5xx)
694
- const errorMsg = `Webhook execution failed. Status: ${response.status}. Response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
695
- logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): ${errorMsg}`);
696
- return {
697
- success: false,
698
- message: errorMsg,
699
- responseStatus: response.status,
700
- responseBody: responseBody
701
- };
702
- }
703
-
704
- } catch (error) {
705
- // Catch network errors or other unexpected errors during the process
706
- const msg = `[handleHttpRequestAction] Action ${actionName} (${actionId}): Unexpected error during webhook execution. Error: ${error.message}`;
707
- logger.error(msg, error.stack);
708
- return { success: false, message: msg };
709
- }
710
- }
711
-
712
- /**
713
- * Handles the 'CreateData' workflow action.
714
- * Substitutes variables, validates, and inserts a new document.
715
- *
716
- * @param {object} actionDef - The definition of the 'CreateData' action.
717
- * @param {object} contextData - The current workflow run context data.
718
- * @param {object} user - The user object.
719
- * @param {object} dbCollection - The MongoDB collection for the user.
720
- * @returns {Promise<{success: boolean, message?: string, insertedId?: ObjectId}>} - Result of the action.
721
- */
722
- async function handleCreateDataAction(actionDef, contextData, user, dbCollection) {
723
- const { targetModel, dataToCreate } = actionDef;
724
-
725
- // 1. Basic Validation
726
- if (!targetModel || typeof targetModel !== 'string') {
727
- const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
728
- logger.error(msg);
729
- return { success: false, message: msg };
730
- }
731
- if (!dataToCreate) {
732
- const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'dataToCreate' template.`;
733
- logger.error(msg);
734
- return { success: false, message: msg };
735
- }
736
-
737
- logger.info(`[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Creating data for model '${targetModel}'.`);
738
-
739
- try {
740
- // 2. Substitute Variables in the data template
741
- let dataObject;
742
-
743
- if (typeof dataToCreate === 'string') {
744
- const substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
745
- try {
746
- // CORRECTION : Utiliser la bonne variable (substitutedDataString)
747
- dataObject = JSON.parse(substitutedDataString);
748
- } catch (parseError) {
749
- const msg = `Failed to parse substituted JSON string: ${substitutedDataString}. Error: ${parseError.message}`;
750
- logger.error(`[handleCreateDataAction] ${msg}`);
751
- return { success: false, message: msg };
752
- }
753
- } else if (typeof dataToCreate === 'object') {
754
- // CORRECTION : Assigner le résultat de la substitution à dataObject.
755
- // On passe une copie pour ne pas muter le template original.
756
- dataObject = await substituteVariables(JSON.parse(JSON.stringify(dataToCreate)), contextData, user);
757
- } else {
758
- const msg = `[handleCreateDataAction] 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
759
- logger.error(msg);
760
- return { success: false, message: msg };
761
- }
762
-
763
- // Log pour débogage
764
- logger.debug('Final data object after substitution:', dataObject);
765
-
766
- // 3. Appeler insertData avec l'objet correctement substitué
767
- const result = await insertData(targetModel, dataObject, [], user, false, true); // On attend la fin du workflow déclenché par cette création
768
-
769
- if (result.success) {
770
- return { success: true, insertedIds: result.insertedIds };
771
- } else {
772
- // Propage l'erreur venant de insertData
773
- return { success: false, message: result.error || "Insertion failed." };
774
- }
775
-
776
- } catch (error) {
777
- const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during creation for model '${targetModel}'. Error: ${error.message}`;
778
- logger.error(msg, error.stack);
779
- return { success: false, message: msg };
780
- }
781
- }
782
-
783
-
784
- /**
785
- * Handles the 'UpdateData' workflow action.
786
- * Finds document(s) based on a selector, substitutes variables in updates,
787
- * validates, and updates the document(s) using the updateData function.
788
- *
789
- * @param {object} actionDef - The definition of the 'UpdateData' action.
790
- * @param {object} contextData - The current workflow run context data.
791
- * @param {object} user - The user object.
792
- * @param {object} dbCollection - The MongoDB collection for the user (bien que updateData utilise getCollectionForUser).
793
- * @returns {Promise<{success: boolean, message?: string, modifiedCount?: number, matchedCount?: number}>} - Result of the action.
794
- */
795
- async function handleUpdateDataAction(actionDef, contextData, user) {
796
- const { targetModel, targetSelector, fieldsToUpdate, updateMultiple = false } = actionDef; // updateMultiple optionnel, défaut false
797
-
798
- // 1. Basic Validation
799
- if (!targetModel || typeof targetModel !== 'string') {
800
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
801
- logger.error(msg);
802
- return { success: false, message: msg };
803
- }
804
- if (!targetSelector) {
805
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
806
- logger.error(msg);
807
- return { success: false, message: msg };
808
- }
809
- if (!fieldsToUpdate) {
810
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'fieldsToUpdate'.`;
811
- logger.error(msg);
812
- return { success: false, message: msg };
813
- }
814
-
815
- logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Updating data for model '${targetModel}'. Multiple: ${updateMultiple}`);
816
-
817
- try {
818
- // 2. Substitute Variables in selector and updates
819
- let substitutedSelectorString;
820
- let substitutedUpdatesString;
821
- let selectorObject;
822
- let updatesObject;
823
-
824
- // Substitute targetSelector (assuming it's a JSON string or object)
825
- if (typeof targetSelector === 'string') {
826
- substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
827
- } else if (typeof targetSelector === 'object') {
828
- selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
829
- } else {
830
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
831
- logger.error(msg);
832
- return { success: false, message: msg };
833
- }
834
-
835
- // Substitute fieldsToUpdate (assuming it's a JSON string or object)
836
- if (typeof fieldsToUpdate === 'string') {
837
- substitutedUpdatesString = await substituteVariables(fieldsToUpdate, contextData, user);
838
- } else if (typeof fieldsToUpdate === 'object') {
839
- updatesObject = await substituteVariables(fieldsToUpdate, contextData, user); // Substitute values within the object
840
- } else {
841
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' has an invalid type (${typeof fieldsToUpdate}). Expected string (JSON) or object.`;
842
- logger.error(msg);
843
- return { success: false, message: msg };
844
- }
845
-
846
- // 3. Parse substituted JSON strings
847
- if (substitutedSelectorString) {
848
- try {
849
- selectorObject = JSON.parse(substitutedSelectorString);
850
- if (typeof selectorObject !== 'object' || selectorObject === null) {
851
- throw new Error("Parsed selector is not a valid object.");
852
- }
853
- } catch (parseError) {
854
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
855
- logger.error(msg);
856
- return { success: false, message: msg };
857
- }
858
- }
859
- if (substitutedUpdatesString) {
860
- try {
861
- updatesObject = JSON.parse(substitutedUpdatesString);
862
- if (typeof updatesObject !== 'object' || updatesObject === null) {
863
- throw new Error("Parsed updates is not a valid object.");
864
- }
865
- } catch (parseError) {
866
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'fieldsToUpdate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedUpdatesString}`;
867
- logger.error(msg);
868
- return { success: false, message: msg };
869
- }
870
- }
871
-
872
- // Remove system fields potentially included in updates by mistake
873
- delete updatesObject._id;
874
- delete updatesObject._model;
875
- delete updatesObject._user;
876
- delete updatesObject._hash;
877
-
878
- if (Object.keys(updatesObject).length === 0) {
879
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' resulted in an empty update object after substitution/parsing. Nothing to update.`;
880
- logger.warn(msg);
881
- return { success: true, message: "No fields to update.", modifiedCount: 0, matchedCount: 0 };
882
- }
883
-
884
- const updateResult = await patchData(
885
- targetModel,
886
- selectorObject,
887
- updatesObject,
888
- {},
889
- user, false
890
- );
891
-
892
- // 6. Return result
893
- if (updateResult.success || updateResult.unmodified) {
894
- logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Update successful for model '${targetModel}'. Matched: ${updateResult.matchedCount}, Modified: ${updateResult.modifiedCount}`);
895
- return {
896
- success: true,
897
- modifiedCount: updateResult.modifiedCount,
898
- matchedCount: updateResult.matchedCount,
899
- message: updateResult.message,
900
- updatedContext: {
901
- triggerData: {...contextData.triggerData || {}, ...updatesObject}
902
- }
903
- };
904
- } else {
905
- // updateData now throws errors, so this 'else' might not be reached often,
906
- // but kept for safety in case it returns { success: false } in some scenarios.
907
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): updateData function reported failure. Message: ${updateResult.error}`;
908
- logger.error(msg);
909
- return { success: false, message: msg };
910
- }
911
-
912
- } catch (error) {
913
- // Catch errors thrown by updateData (validation, permissions, DB errors) or other unexpected errors
914
- const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during update for model '${targetModel}'. Error: ${error.message}`;
915
- logger.error(msg, error.stack);
916
- return { success: false, message: msg };
917
- }
918
- }
919
-
920
-
921
-
922
- /**
923
- * Handles the 'DeleteData' workflow action.
924
- * Finds document(s) based on a selector, substitutes variables,
925
- * and deletes the document(s) using the deleteData function.
926
- *
927
- * @param {object} actionDef - The definition of the 'DeleteData' action.
928
- * @param {object} contextData - The current workflow run context data.
929
- * @param {object} user - The user object.
930
- * @param {object} dbCollection - The MongoDB collection for the user (bien que deleteData utilise getCollectionForUser).
931
- * @returns {Promise<{success: boolean, message?: string, deletedCount?: number}>} - Result of the action.
932
- */
933
- async function handleDeleteDataAction(actionDef, contextData, user, dbCollection) {
934
- // deleteMultiple optionnel, défaut false (supprime un seul par défaut)
935
- const { targetModel, targetSelector, deleteMultiple = false } = actionDef;
936
-
937
- // 1. Basic Validation
938
- if (!targetModel || typeof targetModel !== 'string') {
939
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
940
- logger.error(msg);
941
- return { success: false, message: msg };
942
- }
943
- if (!targetSelector) {
944
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
945
- logger.error(msg);
946
- return { success: false, message: msg };
947
- }
948
-
949
- logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Deleting data for model '${targetModel}'. Multiple: ${deleteMultiple}`);
950
-
951
- try {
952
- // 2. Substitute Variables in selector
953
- let substitutedSelectorString;
954
- let selectorObject;
955
-
956
- // Substitute targetSelector (assuming it's a JSON string or object)
957
- if (typeof targetSelector === 'string') {
958
- substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
959
- } else if (typeof targetSelector === 'object') {
960
- selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
961
- } else {
962
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
963
- logger.error(msg);
964
- return { success: false, message: msg };
965
- }
966
-
967
- // 3. Parse substituted JSON string
968
- if (substitutedSelectorString) {
969
- try {
970
- selectorObject = JSON.parse(substitutedSelectorString);
971
- if (typeof selectorObject !== 'object' || selectorObject === null) {
972
- throw new Error("Parsed selector is not a valid object.");
973
- }
974
- } catch (parseError) {
975
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
976
- logger.error(msg);
977
- return { success: false, message: msg };
978
- }
979
- }
980
-
981
- // 5. Call the centralized deleteData function (à créer dans data.js)
982
- // Cette fonction devra gérer la recherche préalable pour les workflows 'DataDeleted' et la suppression des fichiers.
983
- const deleteResult = await deleteData(
984
- targetModel,
985
- selectorObject,
986
- user
987
- );
988
-
989
- // 6. Return result
990
- if (deleteResult.success) {
991
- logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Delete successful for model '${targetModel}'. Deleted: ${deleteResult.deletedCount}`);
992
- return {
993
- success: true,
994
- deletedCount: deleteResult.deletedCount,
995
- message: deleteResult.message // Pass along messages like "not found"
996
- };
997
- } else {
998
- // deleteData devrait lancer des erreurs, mais on garde ce else par sécurité.
999
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): deleteData function reported failure. Message: ${deleteResult.message}`;
1000
- logger.error(msg);
1001
- return { success: false, message: msg };
1002
- }
1003
-
1004
- } catch (error) {
1005
- // Catch errors thrown by deleteData (permissions, DB errors) or other unexpected errors
1006
- const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during deletion for model '${targetModel}'. Error: ${error.message}`;
1007
- logger.error(msg, error.stack);
1008
- return { success: false, message: msg };
1009
- }
1010
- }
1011
-
1012
- /**
1013
- * Handles the 'ExecuteServiceFunction' workflow action.
1014
- * Acts as a secure bridge between the workflow engine and native service modules.
1015
- *
1016
- * @param {object} actionDef - The action definition.
1017
- * @param {object} contextData - The current workflow context.
1018
- * @param {object} user - The user object.
1019
- * @returns {Promise<{success: boolean, message?: string, updatedContext?: object}>}
1020
- */
1021
- async function handleExecuteServiceFunction(actionDef, contextData, user) {
1022
- const { serviceName, functionName, args: argsTemplate } = actionDef;
1023
-
1024
- if (!serviceName || !functionName) {
1025
- return { success: false, message: "Action requires 'serviceName' and 'functionName'." };
1026
- }
1027
-
1028
- const service = services[serviceName];
1029
- if (!service) {
1030
- return { success: false, message: `Service '${serviceName}' not found in the registry.` };
1031
- }
1032
-
1033
- const func = service[functionName];
1034
- if (typeof func !== 'function') {
1035
- return { success: false, message: `Function '${functionName}' not found in service '${serviceName}'.` };
1036
- }
1037
-
1038
- try {
1039
- // Substitute variables in the arguments array
1040
- const substitutedArgs = Array.isArray(argsTemplate)
1041
- ? await substituteVariables(argsTemplate, contextData, user)
1042
- : [];
1043
-
1044
- logger.info(`[Service Call] Calling ${serviceName}.${functionName} with ${substitutedArgs.length} argument(s).`);
1045
- const result = await func(...substitutedArgs, user);
1046
-
1047
- return {
1048
- success: true,
1049
- updatedContext: { serviceResult: result } // Store result in context
1050
- };
1051
- } catch (error) {
1052
- const msg = `Error executing ${serviceName}.${functionName}: ${error.message}`;
1053
- logger.error(`[Service Call] ${msg}`, error.stack);
1054
- return { success: false, message: msg };
1055
- }
1056
- }
1057
-
1058
- // Dans workflow.js
1059
- export async function executeStepAction(actionDef, contextData, user, dbCollection) {
1060
- logger.info(`[executeStepAction] Executing action type ${actionDef.type} for action ${actionDef._id} (${actionDef.name})`);
1061
-
1062
- try {
1063
- let result;
1064
- switch (actionDef.type) {
1065
- case 'Log':
1066
- logger.info(`[Workflow Log Action] Action: ${actionDef.name}. Contexte:`, contextData);
1067
- result = { success: true, message: 'Log action executed successfully.' }; // <--- CORRECTION
1068
- break;
1069
- case 'HttpRequest':
1070
- result = await handleHttpRequestAction(actionDef, contextData, user, dbCollection);
1071
- break;
1072
- case 'CreateData':
1073
- result = await handleCreateDataAction(actionDef, contextData, user, dbCollection);
1074
- break;
1075
- case 'UpdateData':
1076
- result = await handleUpdateDataAction(actionDef, contextData, user);
1077
- break;
1078
- case 'DeleteData':
1079
- result = await handleDeleteDataAction(actionDef, contextData, user, dbCollection);
1080
- break;
1081
- case 'GenerateAIContent':
1082
- result = await executeGenerateAIContentAction(actionDef, contextData, user);
1083
- break;
1084
- case 'SendEmail':
1085
- result = await handleSendEmailAction(actionDef, contextData, user);
1086
- break;
1087
- case 'Wait':
1088
- result = await handleWaitAction(actionDef, contextData, user);
1089
- break;
1090
- case 'ExecuteScript':
1091
- result = await executeSafeJavascript(actionDef, contextData, user);
1092
- break;
1093
- case 'ExecuteServiceFunction':
1094
- result = await handleExecuteServiceFunction(actionDef, contextData, user);
1095
- break;
1096
- default:
1097
- logger.error(`[executeStepAction] Unknown action type: ${actionDef.type}`);
1098
- return { success: false, message: `Unknown action type: ${actionDef.type}` };
1099
- }
1100
- return result;
1101
- } catch (error) {
1102
- logger.error(`[executeStepAction] Error executing action ${actionDef.name} (${actionDef._id}): ${error.message}`, error.stack);
1103
- return { success: false, message: error.message || 'Action execution failed' };
1104
- }
1105
- }
1106
-
1107
- /**
1108
- * Résout un chemin de variable complexe (ex: "triggerData.order.customer.contact.email")
1109
- * en construisant un pipeline d'agrégation dynamique pour tout récupérer en une seule requête.
1110
- *
1111
- * @param {string} pathString - Le chemin de la variable, ex: "triggerData.order.customer.contact.email".
1112
- * @param {object} initialContext - L'objet de départ (le triggerData).
1113
- * @param {object} user - L'objet utilisateur pour les requêtes DB.
1114
- * @returns {Promise<any>} La valeur résolue.
1115
- */
1116
- async function resolvePathValue(pathString, initialContext, user) {
1117
- const pathParts = pathString.split('.');
1118
- const rootObjectKey = pathParts.shift(); // ex: "triggerData"
1119
-
1120
- // Si le chemin ne commence pas par triggerData ou context, essayer de résoudre directement
1121
- if (rootObjectKey !== 'triggerData' && rootObjectKey !== 'context') {
1122
- let current = initialContext;
1123
- for (const part of [rootObjectKey, ...pathParts]) {
1124
- if (current === null || typeof current === 'undefined') return undefined;
1125
- current = current[part];
1126
- }
1127
- return current;
1128
- }
1129
-
1130
- // Vérifier si c'est un chemin simple qui peut être résolu sans aggregation
1131
- if (pathParts.length === 1) {
1132
- return initialContext[pathParts[0]];
1133
- }
1134
-
1135
- let currentModelName = initialContext._model;
1136
- let currentDocId = new ObjectId(initialContext._id);
1137
- const collection = await getCollectionForUser(user);
1138
-
1139
- // Construire le pipeline d'agrégation
1140
- const pipeline = [
1141
- { $match: { _id: currentDocId } }
1142
- ];
1143
-
1144
- // Itérer sur chaque segment du chemin pour construire les lookups
1145
- for (let i = 0; i < pathParts.length; i++) {
1146
- const segment = pathParts[i];
1147
-
1148
- // Si c'est le dernier segment, on n'a pas besoin de faire un lookup
1149
- if (i === pathParts.length - 1) break;
1150
-
1151
- const modelDef = await getModel(currentModelName, user);
1152
- const fieldDef = modelDef.fields.find(f => f.name === segment);
1153
-
1154
- if (!fieldDef || fieldDef.type !== 'relation') {
1155
- // Si ce n'est pas une relation, on ne peut pas continuer le chemin
1156
- return undefined;
1157
- }
1158
-
1159
- const nextModelName = fieldDef.relation;
1160
- const asField = `__resolved_${segment}`;
1161
-
1162
- pipeline.push({
1163
- $lookup: {
1164
- from: collection.collectionName,
1165
- let: { relationId: `$${segment}` },
1166
- pipeline: [
1167
- {
1168
- $match: {
1169
- $expr: {
1170
- $eq: ["$_id", {
1171
- $cond: {
1172
- if: { $eq: [{ $type: "$$relationId" }, "string"] },
1173
- then: { $toObjectId: "$$relationId" },
1174
- else: "$$relationId"
1175
- }
1176
- }]
1177
- }
1178
- }
1179
- }
1180
- ],
1181
- as: asField
1182
- }
1183
- });
1184
-
1185
- pipeline.push({
1186
- $unwind: {
1187
- path: `$${asField}`,
1188
- preserveNullAndEmptyArrays: true
1189
- }
1190
- });
1191
-
1192
- pipeline.push({
1193
- $addFields: {
1194
- [segment]: `$${asField}`
1195
- }
1196
- });
1197
-
1198
- pipeline.push({ $project: { [asField]: 0 } });
1199
-
1200
- currentModelName = nextModelName;
1201
- }
1202
-
1203
- const results = await collection.aggregate(pipeline).toArray();
1204
-
1205
- if (results.length === 0) {
1206
- return undefined;
1207
- }
1208
-
1209
- // Extraire la valeur finale
1210
- let finalValue = results[0];
1211
- for (const part of pathParts) {
1212
- if (finalValue === null || typeof finalValue === 'undefined') {
1213
- return undefined;
1214
- }
1215
- finalValue = finalValue[part];
1216
- }
1217
-
1218
- return finalValue;
1219
- }
1220
-
1221
- /**
1222
- * Triggers the instantiation of a workflowRun if conditions are met.
1223
- * Checks the event type and trigger's data filter.
1224
- * Creates a 'workflowRun' document for later asynchronous execution.
1225
- *
1226
- * @param {object} triggerData - The data that triggered the workflow(s) (can be a data document or model document).
1227
- * @param {object} user - The associated user.
1228
- * @param {'DataAdded' | 'DataEdited' | 'DataDeleted' | 'ModelAdded' | 'ModelEdited' | 'ModelDeleted'} eventType - The event type.
1229
- */
1230
- export async function triggerWorkflows(triggerData, user, eventType) {
1231
- const trigger = async (triggerData, user, eventType) => {
1232
- // Basic validation
1233
- if (!triggerData || !user || !eventType) {
1234
- console.warn("triggerWorkflows: Invalid call - missing triggerData, user, or eventType.", {
1235
- hasTriggerData: !!triggerData,
1236
- hasUser: !!user,
1237
- eventType
1238
- });
1239
- return;
1240
- }
1241
-
1242
- // Determine model name and data ID based on event type
1243
- const targetModelName = eventType.startsWith('Model') ? triggerData.name : triggerData._model;
1244
- const dataId = eventType.startsWith('Model') ? null : triggerData._id;
1245
-
1246
- if (!targetModelName) {
1247
- console.warn(`triggerWorkflows: Cannot determine model name for event ${eventType}.`, triggerData);
1248
- return;
1249
- }
1250
-
1251
- console.log(`[Workflow Trigger] Event: ${eventType}, Model: ${targetModelName}${dataId ? `, Data ID: ${dataId}` : ''}, User: ${user.username}`);
1252
-
1253
- try {
1254
- const dbCollection = await getCollectionForUser(user);
1255
-
1256
- // 1. Find relevant WorkflowTriggers
1257
- const workflowTriggers = await dbCollection.find({
1258
- _model: 'workflowTrigger',
1259
- targetModel: targetModelName,
1260
- isActive: true,
1261
- onEvent: eventType,
1262
- $or: [{_user: user._user}, {_user: user.username}]
1263
- }).toArray();
1264
-
1265
- if (workflowTriggers.length === 0) {
1266
- console.debug(`[Workflow Trigger] No active triggers found for ${targetModelName}/${eventType}.`);
1267
- return;
1268
- }
1269
- console.debug(`[Workflow Trigger] Found ${workflowTriggers.length} potential trigger(s) for ${targetModelName}/${eventType}.`);
1270
-
1271
- // 2. For each trigger, verify data filter and create workflowRun
1272
- for (const trigger of workflowTriggers) {
1273
- console.debug(`[Workflow Trigger] Evaluating trigger ${trigger._id} (${trigger.name || 'Unnamed'})...`);
1274
-
1275
- // 3. Check data filter if applicable
1276
- if (eventType.startsWith('Data') && trigger.dataFilter) {
1277
- let dataFilterCondition = null;
1278
- try {
1279
- // dataFilter is expected to be stored as an object or valid JSON string
1280
- if (typeof trigger.dataFilter === 'string') {
1281
- dataFilterCondition = JSON.parse(trigger.dataFilter);
1282
- } else if (typeof trigger.dataFilter === 'object' && trigger.dataFilter !== null) {
1283
- dataFilterCondition = trigger.dataFilter;
1284
- }
1285
- } catch (parseError) {
1286
- console.error(`[Workflow Trigger] JSON parsing error for dataFilter in trigger ${trigger._id}:`, parseError);
1287
- continue; // Skip to next trigger if filter is invalid
1288
- }
1289
-
1290
- try {
1291
- const mod = await getModel(targetModelName, user);
1292
- const filterMatches = isConditionMet(mod, dataFilterCondition, triggerData, [], user);
1293
-
1294
- if (!filterMatches) {
1295
- console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter not satisfied by data. Skipping workflowRun creation.`);
1296
- continue;
1297
- }
1298
- console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter satisfied.`);
1299
- } catch (filterError) {
1300
- console.error(`[Workflow Trigger] Error evaluating dataFilter for trigger ${trigger._id}:`, filterError);
1301
- continue;
1302
- }
1303
- }
1304
-
1305
- // 4. If filters passed, create workflowRun instance
1306
- if (!trigger.workflow || !isObjectId(trigger.workflow)) {
1307
- console.warn(`[Workflow Trigger] Trigger ${trigger._id} has no valid associated workflow.`);
1308
- continue;
1309
- }
1310
-
1311
- // a. Verify workflow exists
1312
- const workflowDefinition = await dbCollection.findOne({
1313
- _id: new ObjectId(trigger.workflow),
1314
- _model: 'workflow',
1315
- $or: [{_user: user._user}, {_user: user.username}]
1316
- });
1317
-
1318
- if (!workflowDefinition) {
1319
- console.warn(`[Workflow Trigger] Workflow ${trigger.workflow} associated with trigger ${trigger._id} not found.`);
1320
- continue;
1321
- }
1322
-
1323
- // b. Create workflowRun document
1324
- const workflowRunData = {
1325
- _model: 'workflowRun',
1326
- _user: user._user || user.username,
1327
- workflow: workflowDefinition._id,
1328
- contextData: {
1329
- triggerDataModel: targetModelName,
1330
- triggerData: triggerData
1331
- },
1332
- status: 'pending',
1333
- owner: null,
1334
- startedAt: new Date()
1335
- };
1336
-
1337
- try {
1338
- const insertResult = await dbCollection.insertOne(workflowRunData);
1339
- if (insertResult.insertedId) {
1340
- console.info(`[Workflow Trigger] Created workflowRun ${insertResult.insertedId} for workflow ${workflowDefinition.name} (ID: ${workflowDefinition._id}) triggered by ${trigger._id}.`);
1341
- await workflowModule.processWorkflowRun(insertResult.insertedId, user);
1342
- } else {
1343
- console.error(`[Workflow Trigger] Failed to create workflowRun for workflow ${workflowDefinition._id} (Trigger: ${trigger._id}).`);
1344
- }
1345
- } catch (insertError) {
1346
- console.error(`[Workflow Trigger] Error creating workflowRun for workflow ${workflowDefinition._id} (Trigger: ${trigger._id}):`, insertError);
1347
- }
1348
- }
1349
- } catch (error) {
1350
- console.error(`[Workflow Trigger] General error in triggerWorkflows for ${targetModelName}${dataId ? ` ID: ${dataId}` : ''} (Event: ${eventType}):`, error);
1351
- }
1352
- }
1353
-
1354
- return trigger(triggerData, user, eventType);
1355
- }
1356
- /**
1357
- * Processes a workflowRun instance step-by-step.
1358
- * Fetches the run, evaluates conditions, executes actions, and transitions
1359
- * to the next step based on success or failure, updating the workflowRun status.
1360
- *
1361
- * @param {string|ObjectId} workflowRunId - The ID of the workflowRun to process.
1362
- * @param {object} user - The user context for database access.
1363
- * @returns {Promise<void>}
1364
- */
1365
-
1366
- export async function processWorkflowRun(workflowRunId, user) {
1367
- const dbCollection = await getCollectionForUser(user);
1368
- const runId = typeof workflowRunId === 'string' ? new ObjectId(workflowRunId) : workflowRunId;
1369
-
1370
- logger.info(`[processWorkflowRun] Starting processing for workflowRun ID: ${runId}`);
1371
-
1372
- let currentRunState;
1373
- let contextData = {};
1374
- let stepExecutionsCount = {};
1375
- let history = [];
1376
-
1377
- try {
1378
- currentRunState = await dbCollection.findOne({ _id: runId, _model: 'workflowRun' });
1379
-
1380
- if (!currentRunState) {
1381
- logger.error(`[processWorkflowRun] WorkflowRun ID: ${runId} not found.`);
1382
- return;
1383
- }
1384
-
1385
- stepExecutionsCount = currentRunState.stepExecutionsCount || {};
1386
- history = currentRunState.history || [];
1387
- if (['completed', 'failed', 'cancelled'].includes(currentRunState.status)) {
1388
- logger.info(`[processWorkflowRun] WorkflowRun ID: ${runId} is already in a terminal state (${currentRunState.status}). Skipping.`);
1389
- return;
1390
- }
1391
-
1392
- const logError = async (error) => {
1393
- logger.error(error);
1394
- await dbCollection.updateOne(
1395
- { _id: runId },
1396
- { $set: { status: 'failed', error, completedAt: new Date(), stepExecutionsCount, history } }
1397
- );
1398
- };
1399
-
1400
- const workflowDefinition = await dbCollection.findOne({ _id: new ObjectId(currentRunState.workflow), _model: 'workflow' });
1401
- if (!workflowDefinition) {
1402
- return await logError(`Workflow definition ID: ${currentRunState.workflow} not found.`);
1403
- }
1404
-
1405
- contextData = currentRunState.contextData || {};
1406
- let currentStepId = currentRunState.currentStep || workflowDefinition.startStep;
1407
-
1408
- if (!currentStepId || !isObjectId(currentStepId)) {
1409
- const finalStatus = workflowDefinition.startStep ? 'failed' : 'completed';
1410
- const errorMessage = workflowDefinition.startStep ? 'No valid starting step defined in workflow or run state.' : null;
1411
- await dbCollection.updateOne(
1412
- { _id: runId },
1413
- { $set: { status: finalStatus, error: errorMessage, completedAt: new Date(), currentStep: null, stepExecutionsCount, history } }
1414
- );
1415
- return;
1416
- }
1417
-
1418
- let stepCount = 0;
1419
- const mw = Config.Get('maxWorkflowSteps', maxWorkflowSteps);
1420
- while (currentStepId) {
1421
- if (stepCount++ >= mw) {
1422
- return await logError(`Maximum workflow step executions exceeded (${maxWorkflowSteps} max).`);
1423
- }
1424
-
1425
- const execCount = (stepExecutionsCount[currentStepId] || 0) + 1;
1426
- const m = Config.Get('maxExecutionsByStep', maxExecutionsByStep);
1427
- if (execCount > m) {
1428
- return await logError(`Maximum executions (${m}) exceeded for step ${currentStepId}.`);
1429
- }
1430
- stepExecutionsCount[currentStepId] = execCount;
1431
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Current Step ID: ${currentStepId}`);
1432
-
1433
- const currentStepDef = await dbCollection.findOne({ _id: new ObjectId(currentStepId), _model: 'workflowStep' });
1434
- if (!currentStepDef) {
1435
- return await logError(`Step definition ID: ${currentStepId} not found.`);
1436
- }
1437
-
1438
- const stepHistoryEntry = {
1439
- stepId: currentStepId.toString(),
1440
- stepName: currentStepDef.name,
1441
- executedAt: new Date(),
1442
- status: 'pending',
1443
- actions: []
1444
- };
1445
-
1446
- let stepSucceeded = true;
1447
- let logInfo = null;
1448
- let conditionsMet = true;
1449
-
1450
- try {
1451
- // Add logging to see the actual pipeline being executed
1452
- logger.debug('Executing pipeline:', JSON.stringify(await substituteVariables(currentStepDef.conditions, contextData, user), null, 2));
1453
-
1454
- // And log the context data to verify processedChunk exists
1455
- logger.debug('Context data:', JSON.stringify(contextData, null, 2));
1456
-
1457
- // --- 7. Évaluation des conditions de l'étape ---
1458
- if (currentStepDef.conditions && Object.keys(currentStepDef.conditions).length > 0) {
1459
- const substitutedConditions = await substituteVariables(currentStepDef.conditions, contextData, user);
1460
- // Si un modèle est spécifié dans le contexte, la condition est une requête sur la base de données.
1461
- if (contextData.triggerDataModel) {
1462
- const searchResult = await searchData({ model: contextData.triggerDataModel, filter: substitutedConditions, limit: 1 }, user);
1463
- conditionsMet = searchResult && searchResult.count > 0;
1464
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: DB condition evaluated. Found ${searchResult ? searchResult.count : 0} match(es). Result: ${conditionsMet}`);
1465
- } else {
1466
- console.log({substitutedConditions, c:contextData['triggerData']['event']['type']});
1467
- // Si aucun modèle n'est spécifié (ex: webhook), la condition est évaluée sur l'objet de contexte lui-même.
1468
- conditionsMet = isConditionMet(null, substitutedConditions, contextData, [], user);
1469
-
1470
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Context condition evaluated. Operator: ${JSON.stringify(substitutedConditions)}, Result: ${conditionsMet}`);
1471
- }
1472
- }
1473
-
1474
- // --- 8. Exécution des actions si les conditions sont remplies ---
1475
- if (conditionsMet) {
1476
- if (currentStepDef.actions && currentStepDef.actions.length > 0) {
1477
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Executing ${currentStepDef.actions.length} action(s)...`);
1478
- for (const actionId of currentStepDef.actions) {
1479
- if (!isObjectId(actionId)) continue;
1480
- const actionDef = await dbCollection.findOne({ _id: new ObjectId(actionId), _model: 'workflowAction' });
1481
- const actionHistoryEntry = {
1482
- actionId: actionId.toString(),
1483
- actionName: actionDef.name,
1484
- actionType: actionDef.type,
1485
- executedAt: new Date(),
1486
- status: 'pending'
1487
- };
1488
-
1489
- if (!actionDef) return await logError(`Action definition ${actionId} not found.`);
1490
- const actionResult = await workflowModule.executeStepAction(actionDef, contextData, user, dbCollection);
1491
-
1492
- if (actionResult.status === 'paused') {
1493
- // L'action demande une pause !
1494
- const { duration, durationUnit } = actionResult;
1495
- const now = new Date();
1496
- let resumeAt = new Date(now);
1497
-
1498
- // Calculer la date de reprise
1499
- const ms = { seconds: 1000, minutes: 60000, hours: 3600000, days: 86400000 };
1500
- resumeAt.setTime(now.getTime() + (duration * ms[durationUnit]));
1501
-
1502
- logger.info(`[processWorkflowRun] Run ID: ${runId} is pausing. Will resume at: ${resumeAt.toISOString()}`);
1503
-
1504
- // Mettre à jour le workflowRun avec le statut 'paused' et la date de reprise
1505
- await dbCollection.updateOne({ _id: runId }, {
1506
- $set: {
1507
- status: 'paused',
1508
- currentStep: currentStepDef.onSuccessStep, // On prépare la prochaine étape
1509
- contextData,
1510
- log: actionResult.message
1511
- }
1512
- });
1513
-
1514
- // Planifier le réveil du workflow
1515
- schedule.scheduleJob(resumeAt, async () => {
1516
- logger.info(`[Scheduler] Waking up paused workflowRun ID: ${runId}`);
1517
- // On relance le traitement pour ce workflow spécifique
1518
- await workflowModule.processWorkflowRun(runId, user);
1519
- });
1520
-
1521
- // Arrêter le traitement actuel de cette exécution
1522
- return; // Très important de stopper la boucle ici
1523
- }
1524
- if (!actionResult.success) {
1525
- stepSucceeded = false;
1526
- logInfo = actionResult.message || `Action ${actionDef.name || actionId} failed.`;
1527
- actionHistoryEntry.status = 'failed';
1528
- actionHistoryEntry.result = logInfo;
1529
- stepHistoryEntry.actions.push(actionHistoryEntry);
1530
- break;
1531
- }else{
1532
- logInfo = `Action ${actionDef.name || actionId} : ${actionResult.message}`;
1533
- actionHistoryEntry.status = 'success';
1534
- actionHistoryEntry.result = actionResult.message;
1535
- stepHistoryEntry.actions.push(actionHistoryEntry);
1536
- }
1537
- if (actionResult.updatedContext) {
1538
- contextData = { ...contextData, ...actionResult.updatedContext };
1539
- }
1540
- //console.log("action", util.inspect(actionResult, false, 8, true));
1541
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}, Action ID: ${actionId}: Executed successfully.`);
1542
- }
1543
- }
1544
- if (stepSucceeded) {
1545
- stepHistoryEntry.status = 'success';
1546
- stepHistoryEntry.result = 'Step actions completed successfully.';
1547
- }
1548
- } else {
1549
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Conditions not met. Skipping actions.`);
1550
- }
1551
- } catch (error) {
1552
- logger.error(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Error during condition/action execution: ${error.message}`);
1553
- stepSucceeded = false;
1554
- logInfo = error.message;
1555
- stepHistoryEntry.status = 'failed';
1556
- stepHistoryEntry.result = logInfo;
1557
- }
1558
-
1559
- // --- 9. Détermination de la prochaine étape ---
1560
- let nextStepId = null;
1561
- let finalStatusForRun = null;
1562
-
1563
- if (stepSucceeded && conditionsMet) {
1564
- // CHEMIN SUCCÈS : Les conditions sont remplies et les actions ont réussi.
1565
- logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Step path succeeded.`);
1566
- nextStepId = currentStepDef.onSuccessStep;
1567
- if (currentStepDef.isTerminal || !nextStepId) {
1568
- finalStatusForRun = 'completed';
1569
- nextStepId = null;
1570
- }
1571
- } else {
1572
- // CHEMIN ÉCHEC/BRANCHE : Une action a échoué OU les conditions n'ont pas été remplies.
1573
- const reason = logInfo ? `Action failed: ${logInfo}` : 'Step conditions not met.';
1574
- logger.warn(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Taking failure/branching path. Reason: ${reason}`);
1575
- nextStepId = currentStepDef.onFailureStep;
1576
-
1577
- if (!nextStepId || !isObjectId(nextStepId)) {
1578
- // Fin du workflow. Le statut est 'failed' seulement si une vraie erreur s'est produite.
1579
- finalStatusForRun = logInfo ? 'failed' : 'completed';
1580
- nextStepId = null;
1581
- }
1582
- }
1583
-
1584
- // --- 10. Mise à jour de l'état de l'exécution ---
1585
- currentStepId = nextStepId;
1586
- const updatePayload = { contextData, stepExecutionsCount }; // <-- CORRECTION: Always include stepExecutionsCount
1587
-
1588
- if (finalStatusForRun) {
1589
- updatePayload.status = finalStatusForRun;
1590
- updatePayload.completedAt = new Date();
1591
- updatePayload.currentStep = null;
1592
- updatePayload.log = logInfo;
1593
- } else {
1594
- updatePayload.currentStep = currentStepId;
1595
- }
1596
-
1597
- // Update the last history entry with the final status and actions
1598
- // We use a pipeline to reliably update the last element of the history array.
1599
- // --- FIX ---
1600
- // The previous logic using $slice failed when the history array had only one element.
1601
- // This new logic uses a direct update on the last element of the array, which is more robust.
1602
- await dbCollection.updateOne(
1603
- { _id: runId }, {
1604
- $set: updatePayload,
1605
- $push: {
1606
- history: stepHistoryEntry
1607
- }
1608
- }
1609
- );
1610
-
1611
-
1612
- if(finalStatusForRun) {
1613
- logger.info(`[processWorkflowRun] Finished processing for workflowRun ID: ${runId}. Final Status: ${finalStatusForRun}`);
1614
- }
1615
- }
1616
- } catch (error) {
1617
- logger.error(`[processWorkflowRun] Critical error during processing of workflowRun ID: ${runId}. Error: ${error.message}`, error.stack);
1618
- await dbCollection.updateOne(
1619
- { _id: runId, status: { $nin: ['completed', 'failed', 'cancelled'] } },
1620
- { $set: { status: 'failed', log: `Critical error: ${error.message}`, completedAt: new Date(), stepExecutionsCount, history } }
1621
- );
1622
- }
1623
- }
1624
- /**
1625
- * Executes an AI content generation action ('GenerateAIContent').
1626
- * Retrieves the API key (prioritizing the user's environment), initializes a LangChain client,
1627
- * formats a prompt with context data, calls the LLM, and returns the result
1628
- * to be added to the workflow context.
1629
- *
1630
- * @param {object} action - The action definition from the workflow.
1631
- * @param {object} context - The current workflow execution context.
1632
- * @param {object} user - The user executing the workflow.
1633
- * @returns {Promise<{success: boolean, updatedContext?: object, message?: string}>}
1634
- */
1635
- async function executeGenerateAIContentAction(action, context, user) {
1636
- const { aiProvider, aiModel, prompt } = action;
1637
-
1638
- // 1. Retrieve the API key (User Environment > Machine Environment)
1639
- let apiKey;
1640
-
1641
- const envKeyName = providers[aiProvider].key;
1642
- if( !envKeyName ) {
1643
- return {success: false, message: i18n.t('aiContent.env', `API key for provider ${aiProvider} (${envKeyName}) not found in user environment.`)};
1644
- }
1645
-
1646
- // First look in the user's environment variables
1647
- const envCollection = await getCollectionForUser(user);
1648
- const userEnvVar = await envCollection.findOne({ _model: 'env', name: envKeyName, _user: user.username });
1649
-
1650
- if (userEnvVar && userEnvVar.value) {
1651
- apiKey = userEnvVar.value;
1652
- logger.debug(`[AI Action] Using user environment API key for ${aiProvider}.`);
1653
- } else {
1654
- apiKey = process.env[envKeyName];
1655
- logger.debug(`[AI Action] Using machine environment API key for ${aiProvider}.`);
1656
- }
1657
-
1658
- if (!apiKey) {
1659
- const message = `API key for ${aiProvider} (${envKeyName}) not found in user or machine environment.`;
1660
- logger.error(`[AI Action] ${message}`);
1661
- return { success: false, message };
1662
- }
1663
-
1664
- // 2. Initialize the LLM client with LangChain
1665
- let llm = await getAIProvider(aiProvider, aiModel, apiKey);
1666
- if( !llm ) {
1667
- const message = `Failed to initialize AI client for ${aiProvider}: ${initError.message}`;
1668
- logger.error(`[AI Action] ${message}`);
1669
- return { success: false, message };
1670
- }
1671
-
1672
- try {
1673
- const substitutedPrompt = await substituteVariables(prompt, context, user);
1674
- // 3. Create the "Prompt Template"
1675
- // LangChain handles variable substitution like {triggerData.name}
1676
- const realPrompt = ChatPromptTemplate.fromTemplate(substitutedPrompt);
1677
-
1678
- // 4. Create the processing chain (Prompt + Model)
1679
- const chain = realPrompt.pipe(llm);
1680
-
1681
- // 5. Invoke the chain with the complete context
1682
- // LangChain will automatically replace placeholders in the prompt.
1683
- logger.debug(`[AI Action] Invoking AI with model ${aiModel}.`);
1684
- const response = await chain.invoke(context);
1685
-
1686
- // 6. Prepare the result to be merged into the workflow context
1687
- const llmOutput = response.content;
1688
- const outputVariable = 'aiContent';
1689
- const updatedContext = {
1690
- [outputVariable]: llmOutput
1691
- };
1692
-
1693
- logger.info(`[AI Action] Content generated successfully and stored in context variable '${outputVariable}'.`);
1694
-
1695
- return {
1696
- success: true,
1697
- updatedContext // This object will be merged into the main context by the workflow engine
1698
- };
1699
-
1700
- } catch (llmError) {
1701
- const message = `Error during AI content generation with ${aiProvider}: ${llmError.message}`;
1702
- logger.error(`[AI Action] ${message}`, llmError.stack);
1703
- return { success: false, message };
1704
- }
1705
- }
1706
-
1707
- /**
1708
- * Gère l'action d'envoi d'e-mail d'un workflow.
1709
- * Cette version améliorée peut traiter une liste de destinataires, en envoyant un e-mail
1710
- * individuel et personnalisé à chacun. Elle gère les placeholders dans le sujet et le corps
1711
- * de l'e-mail en se basant sur le contexte de chaque destinataire.
1712
- *
1713
- * @param {object} action - La définition de l'action 'SendEmail'.
1714
- * @param {object} contextData - Le contexte d'exécution actuel du workflow.
1715
- * @param {object} user - L'utilisateur propriétaire du workflow.
1716
- * @returns {Promise<{success: boolean, message: string, data?: {sent: string[], failed: any[]}}>}
1717
- */
1718
- async function handleSendEmailAction(action, contextData, user) {
1719
- logger.info(`[handleSendEmailAction] Executing for user ${user.username}.`);
1720
-
1721
- // 1. Récupérer la configuration SMTP depuis le modèle 'env' de l'utilisateur
1722
- const smtpConfig = await getSmtpConfig(user);
1723
-
1724
- // 2. Valider la configuration de l'action
1725
- const { emailRecipients, emailSubject, emailContent } = action;
1726
- if (!emailRecipients || !emailSubject || !emailContent) {
1727
- const msg = "SendEmail action is incomplete. 'emailRecipients', 'emailSubject', and 'emailContent' are required.";
1728
- logger.error(`[handleSendEmailAction] ${msg}`);
1729
- return { success: false, message: msg };
1730
- }
1731
-
1732
- try {
1733
- // 3. Résoudre la liste des destinataires. Peut être un placeholder qui retourne un tableau.
1734
- let resolvedRecipients = await substituteVariables(emailRecipients, contextData, user);
1735
-
1736
- // S'assurer que nous avons toujours un tableau à parcourir
1737
- if (!Array.isArray(resolvedRecipients)) {
1738
- resolvedRecipients = [resolvedRecipients];
1739
- }
1740
-
1741
- resolvedRecipients = resolvedRecipients.flat();
1742
-
1743
- if (resolvedRecipients.length === 0) {
1744
- return { success: true, message: "No recipients found after substitution. Nothing to send." };
1745
- }
1746
-
1747
- logger.info(`[handleSendEmailAction] Preparing to send emails to ${resolvedRecipients.length} recipient(s).`);
1748
-
1749
- const allPromises = [];
1750
- const sentTo = [];
1751
- const failedFor = [];
1752
-
1753
- // 4. Itérer sur chaque destinataire pour envoyer un e-mail personnalisé
1754
- for (const recipient of resolvedRecipients) {
1755
- // Le destinataire peut être une simple chaîne (email) ou un objet { email: '...', nom: '...' }
1756
- const recipientEmail = typeof recipient === 'object' && recipient !== null ? recipient.email : recipient;
1757
-
1758
- if (!recipientEmail || typeof recipientEmail !== 'string') {
1759
- logger.warn(`[handleSendEmailAction] Skipping an invalid recipient entry:`, recipient);
1760
- failedFor.push(recipient); // Garder une trace de l'entrée invalide
1761
- continue;
1762
- }
1763
-
1764
-
1765
- // 5. Créer un contexte personnalisé pour ce destinataire spécifique
1766
- // Cela permet d'utiliser des placeholders comme {recipient.name}
1767
- const personalizedContext = { ...contextData, recipient };
1768
-
1769
- // 6. Substituer les variables dans le sujet et le contenu pour ce destinataire
1770
- const personalizedSubject = await substituteVariables(emailSubject, personalizedContext, user);
1771
- const personalizedBody = await substituteVariables(emailContent, personalizedContext, user);
1772
-
1773
- const emailData = { title: personalizedSubject, content: personalizedBody };
1774
-
1775
- // 7. Envoyer l'e-mail et suivre son résultat
1776
- const sendPromise = sendEmail([recipientEmail], emailData, smtpConfig, user.lang)
1777
- .then(() => {
1778
- sentTo.push(recipient);
1779
- })
1780
- .catch(err => {
1781
- logger.error(`[handleSendEmailAction] Failed to send email to ${recipientEmail}: ${err.message}`);
1782
- failedFor.push({ recipient: recipientEmail, error: err.message });
1783
- });
1784
-
1785
- allPromises.push(sendPromise);
1786
- }
1787
-
1788
- // Attendre que toutes les tentatives d'envoi soient terminées
1789
- await Promise.all(allPromises);
1790
-
1791
- const summaryMessage = `Email process completed. Sent: ${sentTo.length}. Failed: ${failedFor.length}.`;
1792
- logger.info(`[handleSendEmailAction] ${summaryMessage}`);
1793
-
1794
- // L'action elle-même a réussi, même si certains e-mails ont échoué.
1795
- // Le message de retour et les données fournissent les détails.
1796
- return {
1797
- success: true,
1798
- message: summaryMessage,
1799
- data: {
1800
- sent: sentTo,
1801
- failed: failedFor
1802
- },
1803
- updatedContext: {
1804
- emailResult: {
1805
- sent: sentTo,
1806
- failed: failedFor
1807
- }
1808
- }
1809
- };
1810
-
1811
- } catch (error) {
1812
- const msg = `[handleSendEmailAction] Unexpected error during email processing: ${error.message}`;
1813
- logger.error(msg, error.stack);
1814
- return { success: false, message: msg };
1815
- }
1
+ import {getCollection, getCollectionForUser, isObjectId} from "./mongodb.js";
2
+ import schedule from "node-schedule";
3
+ import {ObjectId} from "mongodb";
4
+ import { Worker } from 'worker_threads';
5
+
6
+ import {Logger} from "../gameObject.js";
7
+ import {deleteData, getModel, insertData, patchData, searchData} from "./data/index.js";
8
+ import { maxExecutionsByStep, maxWorkflowSteps } from "../constants.js";
9
+ import {ChatPromptTemplate} from "@langchain/core/prompts";
10
+ import i18n from "../../src/i18n.js";
11
+ import {sendEmail} from "../email.js";
12
+
13
+ import * as workflowModule from './workflow.js';
14
+ import {isConditionMet} from "../filter.js";
15
+ import { services } from '../services/index.js';
16
+ import {getEnv, getSmtpConfig} from "./user.js";
17
+ import { providers } from "./assistant/constants.js";
18
+ import { getAIProvider } from "./assistant/providers.js";
19
+ import {Config} from "../config.js";
20
+ import {safeAssignObject} from "../core.js";
21
+
22
+ // Add Node.js path utilities for worker_threads
23
+ import { fileURLToPath, URL } from 'node:url';
24
+ import path from "node:path";
25
+ let executionEngine = { type: null, module: null };
26
+ try {
27
+ // Tentative de chargement de 'isolated-vm'.
28
+ // Si cela échoue (ex: incompatibilité d'architecture, build manquant),
29
+ // le serveur pourra quand même démarrer.
30
+
31
+ const ivm = (await import(/* @vite-ignore */ 'isolated-vm')).default;
32
+ executionEngine = { type: 'isolated-vm', module: ivm };
33
+ } catch (e) {
34
+ executionEngine = { type: 'worker_threads', module: null };
35
+ console.warn(`[Module] Le module 'isolated-vm' n'a pas pu être chargé. L'action de workflow 'ExecuteScript' sera désactivée. Erreur : ${e.message}`);
36
+ }
37
+
38
+ /**
39
+ * Récupère une valeur imbriquée dans un objet (ex: 'user.address.city').
40
+ */
41
+ const getNestedValue = (obj, path) => {
42
+ if (!path || !obj) return undefined;
43
+ return path.split('.').reduce((acc, part) => acc && acc[part] !== undefined ? acc[part] : undefined, obj);
44
+ };
45
+
46
+
47
+ /**
48
+ * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
49
+ * Version améliorée avec support des chemins complexes via resolvePathValue.
50
+ */
51
+ export async function substituteVariables(template, contextData, user) {
52
+ // 1. Retourner les types non substituables tels quels
53
+ if (template === null || (typeof template !== 'string' && typeof template !== 'object')) {
54
+ return template;
55
+ }
56
+
57
+ // 2. Gérer les tableaux de manière récursive
58
+ if (Array.isArray(template)) {
59
+ return Promise.all(template.map(item => substituteVariables(item, contextData, user)));
60
+ }
61
+
62
+ // 3. Gérer les objets de manière récursive
63
+ if (typeof template === 'object') {
64
+ const newObj = {};
65
+ for (const key in template) {
66
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
67
+ const val = await substituteVariables(template[key], contextData, user);
68
+ safeAssignObject(newObj, key, val);
69
+ }
70
+ }
71
+ return newObj;
72
+ }
73
+
74
+ // --- À partir d'ici, nous savons que `template` est une chaîne de caractères ---
75
+
76
+ // 4. Construire le contexte complet pour la substitution
77
+ const dbCollection = await getCollectionForUser(user);
78
+ const userEnvVars = await dbCollection.find({ _model: 'env', _user: user.username }).toArray();
79
+ const userEnv = userEnvVars.reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {});
80
+
81
+ // `contextToSearch` contient toutes les données disponibles à sa racine
82
+ const contextToSearch = { ...contextData, env: userEnv };
83
+
84
+ // 5. Logique de résolution de valeur améliorée avec resolvePathValue
85
+ const findValue = async (key) => {
86
+ let path = key.trim();
87
+ if (path.startsWith('context.')) {
88
+ path = path.substring('context.'.length);
89
+ }
90
+ if (path.endsWith('._id')) {
91
+ const basePath = path.slice(0, -4);
92
+ const value = await findValue(basePath);
93
+ return value?._id?.toString(); // Convertit l'ObjectId en string
94
+ }
95
+
96
+ // Gérer les valeurs dynamiques spéciales
97
+ if (path === 'now') {
98
+ return new Date().toISOString();
99
+ } else if (path === 'randomUUID') {
100
+ return crypto.randomUUID();
101
+ } else if( path === "baseUrl" ){
102
+ return process.env.NODE_ENV === 'production' ? 'https://'+getHost()+'/' : 'http://localhost:/'+port;
103
+ }
104
+
105
+ // Détecter si le chemin est complexe (contient plus d'un point)
106
+ if (path.split('.').length > 1) {
107
+ try {
108
+ // Essayer de résoudre le chemin avec resolvePathValue
109
+ const [root, ...rest] = path.split('.');
110
+ // On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
111
+ if (contextToSearch[root]) {
112
+ const resolvedValue = await resolvePathValue(
113
+ rest.join('.'),
114
+ contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
115
+ user
116
+ );
117
+ if (resolvedValue !== undefined) {
118
+ return resolvedValue;
119
+ }
120
+ }
121
+ } catch (error) {
122
+ console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
123
+ // On continue avec la méthode normale si la résolution échoue
124
+ }
125
+ }
126
+
127
+ // Fallback: chercher le chemin dans l'objet de contexte normal
128
+ return getNestedValue(contextToSearch, path);
129
+ };
130
+
131
+ // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
132
+ const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
133
+ if (singlePlaceholderMatch) {
134
+ const key = singlePlaceholderMatch[1];
135
+ const value = await findValue(key);
136
+
137
+ if (value === undefined) {
138
+ return template; // Placeholder not found, return as is.
139
+ }
140
+
141
+ // If the resolved value is a string, it might contain more placeholders.
142
+ // We recursively call substituteVariables on it, but only if it's different
143
+ // from the original template to prevent infinite loops.
144
+ if (typeof value === 'string' && value !== template) {
145
+ return substituteVariables(value, contextData, user);
146
+ }
147
+
148
+ // For non-string values or if value is same as template, return the value.
149
+ return value;
150
+ }
151
+
152
+ // CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
153
+ const placeholderRegex = /\{([^}]+)\}/g;
154
+ const placeholders = [...template.matchAll(placeholderRegex)];
155
+
156
+ // Si aucun placeholder trouvé, retourner la chaîne telle quelle
157
+ if (placeholders.length === 0) {
158
+ return template;
159
+ }
160
+
161
+ // Remplacer chaque placeholder de manière asynchrone
162
+ let result = template;
163
+ for (const [match, key] of placeholders) {
164
+ const value = await findValue(key);
165
+ const replacement = value !== undefined
166
+ ? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
167
+ : match;
168
+ result = result.replace(match, replacement);
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+
175
+ let logger = null;
176
+ export async function onInit(defaultEngine) {
177
+ logger = defaultEngine.getComponent(Logger);
178
+
179
+ await scheduleWorkflowTriggers();
180
+ }
181
+
182
+ /**
183
+ * Déclenche un workflow par son nom et lui passe des données de contexte.
184
+ * C'est la fonction clé à exposer aux endpoints pour lancer des processus métier.
185
+ *
186
+ * @param {string} name - Le nom du workflow à exécuter.
187
+ * @param {object} data - Les données à injecter dans context.triggerData.
188
+ * @param {object} user - L'objet utilisateur qui initie l'action.
189
+ * @returns {Promise<{success: boolean, message?: string, runId?: ObjectId}>}
190
+ */
191
+ export async function runWorkflowByName(name, data, user) {
192
+ if (!name) {
193
+ return { success: false, message: "Workflow name is required." };
194
+ }
195
+
196
+ const dbCollection = await getCollectionForUser(user);
197
+
198
+ // 1. Trouver la définition du workflow par son nom
199
+ const workflowDefinition = await dbCollection.findOne({ _model: 'workflow', name });
200
+
201
+ if (!workflowDefinition) {
202
+ const msg = `Workflow with name "${name}" not found.`;
203
+ logger.error(`[runWorkflowByName] ${msg}`);
204
+ return { success: false, message: msg };
205
+ }
206
+
207
+ // 2. Créer le document workflowRun
208
+ const workflowRunData = {
209
+ _model: 'workflowRun',
210
+ _user: user._user || user.username,
211
+ workflow: workflowDefinition._id,
212
+ contextData: { triggerData: data }, // Les données passées deviennent le triggerData
213
+ status: 'pending',
214
+ startedAt: new Date()
215
+ };
216
+
217
+ const insertResult = await dbCollection.insertOne(workflowRunData);
218
+ logger.info(`[runWorkflowByName] Created workflowRun ${insertResult.insertedId} for workflow "${name}".`);
219
+
220
+ // 3. Lancer le traitement de manière asynchrone
221
+ await processWorkflowRun(insertResult.insertedId, user);
222
+
223
+ return { success: true, runId: insertResult.insertedId };
224
+ }
225
+ /**
226
+ * Exécute une fonction de manière sécurisée en s'assurant qu'une seule instance
227
+ * s'exécute à la fois, grâce à un système de verrouillage distribué basé sur la base de données.
228
+ * Cette fonction est atomique et conçue pour éviter les conditions de course.
229
+ *
230
+ * @param {string} jobId - Un identifiant unique pour la tâche (ex: 'workflowTrigger_monId').
231
+ * @param {Function} jobFunction - La fonction asynchrone à exécuter si le verrou est acquis.
232
+ * @param {number} [lockDurationMinutes=5] - La durée en minutes pendant laquelle le verrou est considéré comme valide.
233
+ * @returns {Promise<void>}
234
+ */
235
+ export async function runScheduledJobWithDbLock(jobId, jobFunction, lockDurationMinutes = 5) {
236
+ const jobsCollection = getCollection('job_locks');
237
+ const now = new Date();
238
+ const lockExpiresAt = new Date(now.getTime() + lockDurationMinutes * 60 * 1000);
239
+ let lockAcquired = false; // Drapeau pour savoir si nous devons libérer le verrou
240
+
241
+ // Le bloc try...finally garantit que la libération du verrou est tentée
242
+ // même si la fonction jobFunction lève une exception.
243
+ try {
244
+ // --- PHASE 1: ACQUISITION DU VERROU (de manière atomique) ---
245
+
246
+ // Tentative 1: Mettre à jour un verrou existant qui a expiré.
247
+ // C'est le cas le plus courant après la première exécution.
248
+ // L'opération `updateOne` est atomique.
249
+ const updateResult = await jobsCollection.updateOne(
250
+ {
251
+ jobId: jobId,
252
+ lockedUntil: { $lt: now } // Le verrou est disponible si sa date d'expiration est dans le passé
253
+ },
254
+ {
255
+ $set: { lockedUntil: lockExpiresAt, lastStarted: now },
256
+ $inc: { runCount: 1 }
257
+ }
258
+ );
259
+
260
+ if (updateResult.modifiedCount === 1) {
261
+ // Succès : nous avons mis à jour le verrou expiré et l'avons acquis.
262
+ lockAcquired = true;
263
+ logger.info(`[Lock] Verrou existant acquis pour la tâche ${jobId}.`);
264
+ } else {
265
+ // Si aucun document n'a été modifié, soit le verrou n'existe pas,
266
+ // soit il est actuellement détenu par un autre processus.
267
+ // Tentative 2: Insérer un nouveau document de verrou.
268
+ // Cette opération échouera avec une erreur de clé dupliquée (code 11000)
269
+ // si un autre processus a réussi à créer le verrou entre-temps.
270
+ try {
271
+ await jobsCollection.insertOne({
272
+ jobId: jobId,
273
+ lockedUntil: lockExpiresAt,
274
+ lastStarted: now,
275
+ runCount: 1
276
+ });
277
+ // Succès : nous avons créé un nouveau verrou et l'avons acquis.
278
+ lockAcquired = true;
279
+ logger.info(`[Lock] Nouveau verrou créé pour la tâche ${jobId}.`);
280
+ } catch (insertError) {
281
+ if (insertError.code === 11000) {
282
+ // Comportement attendu : un autre processus a acquis le verrou.
283
+ // Ce n'est pas une erreur, on saute simplement l'exécution.
284
+ logger.info(`[Lock] Impossible d'acquérir le verrou pour ${jobId}, un autre processus le dent. Exécution ignorée.`);
285
+ } else {
286
+ // Une erreur de base de données inattendue s'est produite.
287
+ throw insertError;
288
+ }
289
+ }
290
+ }
291
+
292
+ // --- PHASE 2: EXÉCUTION DE LA TÂCHE ---
293
+ if (lockAcquired) {
294
+ logger.info(`[Lock] Exécution de la fonction pour la tâche ${jobId}...`);
295
+ await jobFunction();
296
+ logger.info(`[Lock] La fonction pour la tâche ${jobId} s'est terminée.`);
297
+ }
298
+
299
+ } catch (error) {
300
+ // Capture les erreurs de la `jobFunction` ou les erreurs inattendues de la base de données.
301
+ logger.error(`Erreur durant l'exécution de la tâche verrouillée ${jobId}:`, error);
302
+ } finally {
303
+ // --- PHASE 3: LIBÉRATION DU VERROU ---
304
+ if (lockAcquired) {
305
+ try {
306
+ // On libère le verrou en mettant sa date d'expiration dans le passé,
307
+ // le rendant immédiatement disponible pour la prochaine exécution.
308
+ await jobsCollection.updateOne(
309
+ { jobId: jobId },
310
+ { $set: { lockedUntil: new Date(0) } }
311
+ );
312
+ logger.info(`[Lock] Verrou libéré pour la tâche ${jobId}.`);
313
+ } catch (releaseError) {
314
+ // Il est crucial de logger cette erreur, car un verrou non libéré peut bloquer les futures exécutions.
315
+ logger.error(`CRITIQUE: Échec de la libération du verrou pour la tâche ${jobId}. Une intervention manuelle peut être nécessaire.`, releaseError);
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+
322
+ /**
323
+ * Planifie l'exécution des workflows déclenchés par une cronExpression.
324
+ * Utilise runScheduledJobWithDbLock pour assurer l'exécution unique à travers plusieurs instances.
325
+ */
326
+ export async function scheduleWorkflowTriggers() {
327
+ logger.info('Starting scheduling of workflow triggers...');
328
+ try {
329
+ const datasCollection = getCollection('datas'); // Ou la collection appropriée pour les workflows
330
+
331
+ // Trouver tous les workflows actifs avec une cronExpression définie
332
+ const workflowsToSchedule = await datasCollection.find({
333
+ _model: 'workflowTrigger',
334
+ cronExpression: { $exists: true, $ne: "" }
335
+ // Ajoutez d'autres conditions si nécessaire (ex: active: true)
336
+ }).toArray();
337
+
338
+ console.log(`Found ${workflowsToSchedule.length} workflow triggers with cron expressions to schedule.`);
339
+
340
+ for (const workflow of workflowsToSchedule) {
341
+ const jobId = `workflowTrigger_${workflow._id}`; // ID unique pour le verrou du job
342
+ const cronExpression = workflow.cronExpression;
343
+ if( !cronExpression )
344
+ continue;
345
+ // Planifier la tâche en utilisant node-schedule
346
+ schedule.scheduleJob(cronExpression, async () => {
347
+ console.log(`Cron triggered for job ${jobId}. Attempting to run with lock...`);
348
+
349
+ // Utiliser runScheduledJobWithDbLock pour exécuter la tâche
350
+ await runScheduledJobWithDbLock(
351
+ jobId,
352
+ async () => {
353
+ // --- Début de la logique spécifique au workflow ---
354
+ // C'est ici que vous mettriez le code qui doit être exécuté
355
+ // lorsque le workflow est déclenché par le cron.
356
+ // Par exemple:
357
+ console.log(`Executing task logic for workflow ${workflow.name} (ID: ${workflow._id})`);
358
+
359
+ // Exemple:
360
+ // const targetModel = workflow.targetModel;
361
+ // const action = workflow.action;
362
+ // await executeWorkflowAction(targetModel, action, workflow.parameters);
363
+
364
+ // Simule une tâche asynchrone
365
+ await new Promise(resolve => setTimeout(resolve, 2000));
366
+
367
+ console.log(`Task logic completed for workflow ${workflow.name} (ID: ${workflow._id})`);
368
+ // --- Fin de la logique spécifique au workflow ---
369
+ },
370
+ workflow.lockDurationMinutes || 5 // Utilise la durée du workflow ou une valeur par défaut
371
+ );
372
+ });
373
+
374
+ }
375
+
376
+ console.log('Finished scheduling workflow triggers.');
377
+
378
+ } catch (error) {
379
+ console.error('Error during scheduling of workflow triggers:', error);
380
+ }
381
+ }
382
+
383
+ async function handleWaitAction(actionDef, contextData, user) {
384
+ const { duration, durationUnit } = actionDef;
385
+ if (!duration || !durationUnit) {
386
+ return { success: false, message: "Wait action requires 'duration' and 'durationUnit'." };
387
+ }
388
+
389
+ // Retourne un statut spécial que le moteur de workflow comprendra
390
+ return {
391
+ success: true,
392
+ status: 'paused', // Statut spécial
393
+ duration,
394
+ durationUnit,
395
+ message: `Workflow will be paused for ${duration} ${durationUnit}.`
396
+ };
397
+ }
398
+
399
+
400
+ async function executeWithWorkerThreads(actionDef, context, user) {
401
+ const TIMEOUT = 5000;
402
+ const collectedLogs = [];
403
+ const code = actionDef.script;
404
+ // Contexte sécurisé
405
+ const safeContext = JSON.parse(JSON.stringify(context));
406
+
407
+ return new Promise((resolve, reject) => {
408
+ // Construct an absolute file path for the worker. This is the most reliable method,
409
+ // ensuring Node.js loads the script directly without ambiguity.
410
+ const workerPath = path.dirname('src/workers/worker-script-runner.js');
411
+
412
+ const worker = new Worker(workerPath);
413
+
414
+ const timeoutId = setTimeout(() => {
415
+ worker.terminate();
416
+ const errorMessage = `Script execution timed out after ${TIMEOUT}ms.`;
417
+ collectedLogs.push({level: 'critical', message: errorMessage, timestamp: new Date().toISOString()});
418
+ resolve({success: false, message: errorMessage, logs: collectedLogs});
419
+ }, TIMEOUT);
420
+
421
+ worker.on('message', async (msg) => {
422
+ if (msg.type === 'call') {
423
+ const {callId, service, method, args} = msg;
424
+ try {
425
+ let result;
426
+ // Recréez ici la logique d'appel des fonctions natives
427
+ // C'est un pont entre le worker et votre application principale.
428
+ if (service === 'db') {
429
+ if (method === 'find') result = await searchData({
430
+ model: args[0],
431
+ filter: args[1], ...args[2]
432
+ }, user);
433
+ else if (method === 'findOne') result = (await searchData({
434
+ model: args[0],
435
+ filter: args[1],
436
+ limit: 1
437
+ }, user))?.data?.[0] || null;
438
+ else if (method === 'create') result = await insertData(args[0], args[1], {}, user, false);
439
+ else if (method === 'update') result = await patchData(args[0], args[1], args[2], {}, user, false);
440
+ else if (method === 'delete') result = await deleteData(args[0], args[1], user, false);
441
+ } else if (service === 'workflow') {
442
+ if (method === 'run') result = await runWorkflowByName(args[0], args[1], user);
443
+ } else if (service === 'log') {
444
+ const message = args.join(' ');
445
+ collectedLogs.push({level: method, message, timestamp: new Date().toISOString()});
446
+ logger.trace(`[Worker Script Log - ${method}]`, message);
447
+ result = {success: true};
448
+ } else if (service === 'env') {
449
+ if (method === 'get') result = (await searchData({
450
+ model: 'env',
451
+ filter: {name: args[0]},
452
+ limit: 1
453
+ }, user))?.data?.[0]?.value || null;
454
+ else if (method === 'getAll') result = await getEnv(user);
455
+ } else if (service === 'http') {
456
+ const [methodHttp, url, options] = args;
457
+ const fetchOptions = {
458
+ method: methodHttp.toUpperCase(),
459
+ headers: options.headers || {},
460
+ body: options.body ? (typeof options.body === 'object' ? JSON.stringify(options.body) : options.body) : undefined
461
+ };
462
+ const response = await fetch(url, fetchOptions);
463
+ const responseBody = await response.json().catch(() => response.text());
464
+ result = {success: response.ok, status: response.status, body: responseBody};
465
+ }
466
+
467
+ worker.postMessage({type: 'response', callId, result});
468
+ } catch (error) {
469
+ worker.postMessage({type: 'response', callId, error: error.message});
470
+ }
471
+ } else if (msg.type === 'done') {
472
+ clearTimeout(timeoutId);
473
+ worker.terminate();
474
+ resolve({success: true, data: msg.result, logs: collectedLogs, updatedContext: {result: msg.result}});
475
+ } else if (msg.type === 'error') {
476
+ clearTimeout(timeoutId);
477
+ worker.terminate();
478
+ const errorMessage = `Script execution failed: ${msg.error}`;
479
+ collectedLogs.push({level: 'critical', message: errorMessage, timestamp: new Date().toISOString()});
480
+ resolve({success: false, message: errorMessage, logs: collectedLogs});
481
+ }
482
+ });
483
+
484
+ worker.on('error', (err) => {
485
+ clearTimeout(timeoutId);
486
+ const errorMessage = `Worker error: ${err.message}`;
487
+ collectedLogs.push({level: 'critical', message: errorMessage, timestamp: new Date().toISOString()});
488
+ resolve({success: false, message: errorMessage, logs: collectedLogs});
489
+ });
490
+
491
+ worker.postMessage({code, context: safeContext});
492
+ });
493
+ }
494
+
495
+ async function executeWithIsolatedVm(actionDef, context, user) {
496
+ const ivm = executionEngine.module;
497
+ const code = actionDef.script;
498
+ const collectedLogs = [];
499
+ const isolate = new ivm.Isolate({ memoryLimit: 128 }); // 128MB memory limit
500
+
501
+ try {
502
+ const vmContext = await isolate.createContext();
503
+ const jail = vmContext.global;
504
+
505
+ const find = async (modelName, filter, options) => {
506
+ const parsedOptions = options ? JSON.parse(options) : {};
507
+ const result = await searchData({ model: modelName, filter: JSON.parse(filter), ...parsedOptions }, user);
508
+ return new ivm.ExternalCopy(result).copyInto();
509
+ };
510
+ const findOne = async (modelName, filter) => {
511
+ const result = await searchData({ model: modelName, filter: JSON.parse(filter), limit: 1 }, user);
512
+ return new ivm.ExternalCopy(result.data?.[0] || null).copyInto();
513
+ };
514
+
515
+ // 1. Build the sandboxed API methods
516
+ await jail.set('_workflow_run', new ivm.Reference(async (name, contextData) => {
517
+ const result = await runWorkflowByName(name, JSON.parse(contextData), user);
518
+ return new ivm.ExternalCopy(result).copyInto();
519
+ }));
520
+ await jail.set('_db_create', new ivm.Reference(async (modelName, dataObject) => {
521
+ const result = await insertData(modelName, JSON.parse(dataObject), {}, user, false);
522
+ if (result.success && result.insertedIds) {
523
+ result.insertedIds = result.insertedIds.map(id => id.toString());
524
+ }
525
+ return new ivm.ExternalCopy(result).copyInto();
526
+ }));
527
+ await jail.set('_db_find', new ivm.Reference(find));
528
+ await jail.set('_db_findOne', new ivm.Reference(findOne));
529
+
530
+ await jail.set('_db_update', new ivm.Reference(async (modelName, filter, updateObject) => {
531
+ const result = await patchData(modelName, JSON.parse(filter), JSON.parse(updateObject), {}, user, false);
532
+ return new ivm.ExternalCopy(result).copyInto();
533
+ }));
534
+ await jail.set('_db_delete', new ivm.Reference(async (modelName, filter) => {
535
+ const result = await deleteData(modelName, JSON.parse(filter), user, false);
536
+ return new ivm.ExternalCopy(result).copyInto();
537
+ }));
538
+
539
+ const createLoggerMethod = (level) => {
540
+ return (...args) => {
541
+ const message = args.join(' ');
542
+ collectedLogs.push({
543
+ level,
544
+ message,
545
+ timestamp: new Date().toISOString()
546
+ });
547
+ logger.trace(level, '[VM Script]', message);
548
+ };
549
+ };
550
+
551
+ await jail.set('_log_info', createLoggerMethod('info'));
552
+ await jail.set('_log_warn', createLoggerMethod('warn'));
553
+ await jail.set('_log_error', createLoggerMethod('error'));
554
+ await jail.set('_env_get', new ivm.Reference(async (variableName) => {
555
+ if (!variableName) return null;
556
+ const result = await searchData({ model: 'env', filter: { name: variableName }, limit: 1 }, user);
557
+ return new ivm.ExternalCopy(result.data?.[0]?.value || null).copyInto();
558
+ }));
559
+ await jail.set('_env_get_all', new ivm.Reference(async () => {
560
+ const result = await getEnv(user);
561
+ return new ivm.ExternalCopy(result).copyInto();
562
+ }));
563
+ await jail.set('_http_request', new ivm.Reference(async (method, url, optionsStr) => {
564
+ try {
565
+ const options = optionsStr ? JSON.parse(optionsStr) : {};
566
+ const fetchOptions = {
567
+ method: method.toUpperCase(),
568
+ headers: options.headers || {},
569
+ body: options.body ? (typeof options.body === 'object' ? JSON.stringify(options.body) : options.body) : undefined
570
+ };
571
+
572
+ const response = await fetch(url, fetchOptions);
573
+ const responseBody = await response.json().catch(() => response.text());
574
+
575
+ const result = { success: response.ok, status: response.status, body: responseBody };
576
+ return new ivm.ExternalCopy(result).copyInto();
577
+ } catch (error) {
578
+ logger.error(`[VM http_request] Error: ${error.message}`);
579
+ return new ivm.ExternalCopy({ success: false, message: error.message }).copyInto();
580
+ }
581
+ }));
582
+
583
+ // Contexte sécurisé
584
+ const safeContext = JSON.parse(JSON.stringify(context));
585
+
586
+ await jail.set('context', new ivm.ExternalCopy(safeContext).copyInto());
587
+
588
+ // Exécution
589
+ const fullScript = `
590
+ const normalizeArgs = args => args.map(arg => {
591
+ if (typeof arg === 'object' && arg !== null) {
592
+ return JSON.stringify(arg); // Convert objects to strings
593
+ }
594
+ return arg;
595
+ });
596
+ const db = {
597
+ create: (...args) => _db_create.applySyncPromise(null, normalizeArgs(args)),
598
+ find: (...args) => _db_find.applySyncPromise(null, normalizeArgs(args)),
599
+ findOne: (...args) => _db_findOne.applySyncPromise(null, normalizeArgs(args)),
600
+ update: (...args) => _db_update.applySyncPromise(null, normalizeArgs(args)),
601
+ delete: (...args) => _db_delete.applySyncPromise(null, normalizeArgs(args))
602
+ };
603
+
604
+ const workflow = {
605
+ run: (...args) => _workflow_run.applySyncPromise(null, normalizeArgs(args))
606
+ };
607
+
608
+ const logger = {
609
+ info: _log_info,
610
+ warn: _log_warn,
611
+ error: _log_error
612
+ };
613
+
614
+ const env = {
615
+ get: _env_get,
616
+ getAll: _env_get_all
617
+ };
618
+
619
+ const http = {
620
+ request: (...args) => _http_request.applySyncPromise(null, normalizeArgs(args))
621
+ };
622
+
623
+ (async function() {
624
+ ${code}
625
+ })();
626
+ `;
627
+
628
+ const TIMEOUT = 5000;
629
+ const script = await isolate.compileScript(fullScript, { timeout: TIMEOUT });
630
+ const result = await script.run(vmContext, {
631
+ timeout: TIMEOUT,
632
+ promise: true,
633
+ copy: true // Copie automatique du résultat
634
+ });
635
+
636
+ // Vérifier si le script lui-même a signalé un échec.
637
+ if (result && typeof result === 'object' && result.success === false) {
638
+ const scriptMessage = result.message || 'Le script a signalé un échec sans message.';
639
+ collectedLogs.push({
640
+ level: 'warn',
641
+ message: `Script reported failure: ${scriptMessage}`,
642
+ timestamp: new Date().toISOString()
643
+ });
644
+ return {
645
+ success: false,
646
+ message: scriptMessage,
647
+ logs: collectedLogs
648
+ };
649
+ }
650
+
651
+ return { success: true, data: result, logs: collectedLogs, updatedContext: { result } };
652
+ } catch (error) {
653
+ const errorMessage = `Script execution failed: ${error.message}`;
654
+ const finalErrorMessage = logger.trace('critical', `[VM Script] ${errorMessage}\n${error.stack}`);
655
+ collectedLogs.push({
656
+ level: 'critical',
657
+ message: finalErrorMessage,
658
+ timestamp: new Date().toISOString()
659
+ });
660
+ return { success: false, message: errorMessage, logs: collectedLogs };
661
+ } finally {
662
+ // 3. CRUCIAL: Dispose of the isolate to prevent memory leaks
663
+ if (isolate && !isolate.isDisposed) {
664
+ isolate.dispose();
665
+ }
666
+ }
667
+ }
668
+
669
+ export async function executeSafeJavascript(actionDef, context, user) {
670
+ // Vérifie si un moteur d'exécution est disponible.
671
+ if (!executionEngine.type) {
672
+ const errorMessage = "L'action 'ExecuteScript' est désactivée car aucun moteur d'exécution (isolated-vm ou fallback) n'a pu être initialisé.";
673
+ logger.error(`[Script Engine] ${errorMessage}`);
674
+ return { success: false, message: errorMessage, logs: [{ level: 'critical', message: errorMessage, timestamp: new Date().toISOString() }] };
675
+ }
676
+
677
+ logger.info(`[Script Engine] Using '${executionEngine.type}' for script execution.`);
678
+
679
+ if (executionEngine.type === 'isolated-vm') {
680
+ return executeWithIsolatedVm(actionDef, context, user);
681
+ } else { // 'worker_threads'
682
+ return executeWithWorkerThreads(actionDef, context, user);
683
+ }
684
+ }
685
+ /**
686
+ * Handles the 'HttpRequest' workflow action.
687
+ * Sends an HTTP request to a specified URL with substituted data using native fetch.
688
+ *
689
+ * @param {object} actionDef - The definition of the 'Webhook' action.
690
+ * @param {object} contextData - The current workflow run context data.
691
+ * @param {object} user - The user object (peut être utilisé pour l'authentification ou le logging).
692
+ * @param {object} dbCollection - The MongoDB collection (moins pertinent ici, mais gardé pour la cohérence).
693
+ * @returns {Promise<{success: boolean, message?: string, responseStatus?: number, responseBody?: any}>} - Result of the action.
694
+ */
695
+ async function handleHttpRequestAction(actionDef, contextData, user, dbCollection) {
696
+
697
+ try {
698
+ const {
699
+ method = 'GET',
700
+ url,
701
+ headers: headersTemplate,
702
+ body: bodyTemplate,
703
+ name: actionName = 'Unnamed Action',
704
+ _id: actionId = 'unknown'
705
+ } = actionDef;
706
+
707
+ // 2. Substitute Variables
708
+ const substitutedUrl = await substituteVariables(url, contextData, user);
709
+ let substitutedHeadersString;
710
+ let substitutedBodyString;
711
+ let headersObject = {};
712
+ let bodyObject = null;
713
+
714
+ // Substitute Headers (JSON string or object)
715
+ if (headersTemplate) {
716
+ if (typeof headersTemplate === 'string') {
717
+ substitutedHeadersString = await substituteVariables(headersTemplate, contextData, user);
718
+ } else if (typeof headersTemplate === 'object') {
719
+ headersObject = await substituteVariables(headersTemplate, contextData, user);
720
+ } else {
721
+ logger.warn(`[handleHttpRequestAction] Action ${actionName} (${actionId}): 'headers' has an invalid type (${typeof headersTemplate}). Ignoring.`);
722
+ }
723
+ }
724
+
725
+ // Substitute Body (JSON string or object) - only relevant for methods like POST, PUT, PATCH
726
+ if (bodyTemplate && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
727
+ if (typeof bodyTemplate === 'string') {
728
+ substitutedBodyString = await substituteVariables(bodyTemplate, contextData, user);
729
+ } else if (typeof bodyTemplate === 'object') {
730
+ bodyObject = await substituteVariables(bodyTemplate, contextData, user);
731
+ } else {
732
+ logger.warn(`[handleHttpRequestAction] Action ${actionName} (${actionId}): 'body' has an invalid type (${typeof bodyTemplate}). Ignoring.`);
733
+ }
734
+ }
735
+
736
+ // 3. Parse substituted JSON strings
737
+ if (substitutedHeadersString) {
738
+ try {
739
+ headersObject = JSON.parse(substitutedHeadersString);
740
+ if (typeof headersObject !== 'object' || headersObject === null) {
741
+ throw new Error("Parsed headers is not a valid object.");
742
+ }
743
+ } catch (parseError) {
744
+ logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Failed to parse substituted 'headers' JSON. Error: ${parseError.message}. Using default headers. Substituted string: ${substitutedHeadersString}`);
745
+ headersObject = { 'Content-Type': 'application/json' }; // Fallback
746
+ }
747
+ }
748
+ // Ensure Content-Type if body is present and headers don't specify it
749
+ if (bodyObject !== null || substitutedBodyString) {
750
+ if (!headersObject['Content-Type'] && !headersObject['content-type']) {
751
+ headersObject['Content-Type'] = 'application/json';
752
+ }
753
+ }
754
+
755
+
756
+ if (substitutedBodyString) {
757
+ try {
758
+ // Try parsing first, maybe it's valid JSON already
759
+ bodyObject = JSON.parse(substitutedBodyString);
760
+ } catch (parseError) {
761
+ // If parsing fails, treat it as a plain string body
762
+ bodyObject = substitutedBodyString;
763
+ // Adjust Content-Type if it was assumed to be JSON
764
+ if (headersObject['Content-Type'] === 'application/json') {
765
+ headersObject['Content-Type'] = 'text/plain';
766
+ }
767
+ }
768
+ }
769
+
770
+ // 4. Prepare Fetch Options
771
+ const fetchOptions = {
772
+ method: method.toUpperCase(),
773
+ headers: headersObject // Native fetch accepts an object directly
774
+ };
775
+
776
+ if (bodyObject !== null && ['POST', 'PUT', 'PATCH'].includes(fetchOptions.method)) {
777
+ // Stringify if it's an object and content type is JSON, otherwise use as is
778
+ if (typeof bodyObject === 'object' && headersObject['Content-Type'] === 'application/json') {
779
+ fetchOptions.body = JSON.stringify(bodyObject);
780
+ } else {
781
+ fetchOptions.body = bodyObject; // Use string directly
782
+ }
783
+ }
784
+
785
+ // 5. Execute Fetch Request using native fetch
786
+ logger.info(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Calling URL: ${substitutedUrl}`);
787
+ const response = await fetch(substitutedUrl, fetchOptions); // Utilisation de fetch natif
788
+
789
+ // 6. Process Response
790
+ let responseBody;
791
+ const contentType = response.headers.get('content-type');
792
+ try {
793
+ if (contentType && contentType.includes('application/json')) {
794
+ responseBody = await response.json();
795
+ } else {
796
+ responseBody = await response.text();
797
+ }
798
+ } catch (responseParseError) {
799
+ logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Failed to parse response body. Error: ${responseParseError.message}`);
800
+ // Try reading as text again in case of error during json parsing
801
+ try {
802
+ responseBody = await response.text();
803
+ } catch (textError) {
804
+ responseBody = "[Could not parse response body]";
805
+ }
806
+ }
807
+
808
+ logger.info(`[handleHttpRequestAction] Action ${actionName} (${actionId}): Received response. Status: ${response.status}`);
809
+
810
+ // 7. Return Result
811
+ if (response.ok) { // Status code 200-299
812
+ return {
813
+ success: true,
814
+ message: `Webhook executed successfully. Status: ${response.status}`,
815
+ responseStatus: response.status,
816
+ responseBody: responseBody,
817
+ updatedContext: { httpResponse: responseBody }
818
+ };
819
+ } else {
820
+ // Handle non-successful responses (4xx, 5xx)
821
+ const errorMsg = `Webhook execution failed. Status: ${response.status}. Response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
822
+ logger.error(`[handleHttpRequestAction] Action ${actionName} (${actionId}): ${errorMsg}`);
823
+ return {
824
+ success: false,
825
+ message: errorMsg,
826
+ responseStatus: response.status,
827
+ responseBody: responseBody
828
+ };
829
+ }
830
+
831
+ } catch (error) {
832
+ // Catch network errors or other unexpected errors during the process
833
+ const msg = `[handleHttpRequestAction] Action ${actionName} (${actionId}): Unexpected error during webhook execution. Error: ${error.message}`;
834
+ logger.error(msg, error.stack);
835
+ return { success: false, message: msg };
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Handles the 'CreateData' workflow action.
841
+ * Substitutes variables, validates, and inserts a new document.
842
+ *
843
+ * @param {object} actionDef - The definition of the 'CreateData' action.
844
+ * @param {object} contextData - The current workflow run context data.
845
+ * @param {object} user - The user object.
846
+ * @param {object} dbCollection - The MongoDB collection for the user.
847
+ * @returns {Promise<{success: boolean, message?: string, insertedId?: ObjectId}>} - Result of the action.
848
+ */
849
+ async function handleCreateDataAction(actionDef, contextData, user, dbCollection) {
850
+ const { targetModel, dataToCreate } = actionDef;
851
+
852
+ // 1. Basic Validation
853
+ if (!targetModel || typeof targetModel !== 'string') {
854
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
855
+ logger.error(msg);
856
+ return { success: false, message: msg };
857
+ }
858
+ if (!dataToCreate) {
859
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'dataToCreate' template.`;
860
+ logger.error(msg);
861
+ return { success: false, message: msg };
862
+ }
863
+
864
+ logger.info(`[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Creating data for model '${targetModel}'.`);
865
+
866
+ try {
867
+ // 2. Substitute Variables in the data template
868
+ let dataObject;
869
+
870
+ if (typeof dataToCreate === 'string') {
871
+ const substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
872
+ try {
873
+ // CORRECTION : Utiliser la bonne variable (substitutedDataString)
874
+ dataObject = JSON.parse(substitutedDataString);
875
+ } catch (parseError) {
876
+ const msg = `Failed to parse substituted JSON string: ${substitutedDataString}. Error: ${parseError.message}`;
877
+ logger.error(`[handleCreateDataAction] ${msg}`);
878
+ return { success: false, message: msg };
879
+ }
880
+ } else if (typeof dataToCreate === 'object') {
881
+ // CORRECTION : Assigner le résultat de la substitution à dataObject.
882
+ // On passe une copie pour ne pas muter le template original.
883
+ dataObject = await substituteVariables(JSON.parse(JSON.stringify(dataToCreate)), contextData, user);
884
+ } else {
885
+ const msg = `[handleCreateDataAction] 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
886
+ logger.error(msg);
887
+ return { success: false, message: msg };
888
+ }
889
+
890
+ // Log pour débogage
891
+ logger.debug('Final data object after substitution:', dataObject);
892
+
893
+ // 3. Appeler insertData avec l'objet correctement substitué
894
+ const result = await insertData(targetModel, dataObject, [], user, false, true); // On attend la fin du workflow déclenché par cette création
895
+
896
+ if (result.success) {
897
+ return { success: true, insertedIds: result.insertedIds };
898
+ } else {
899
+ // Propage l'erreur venant de insertData
900
+ return { success: false, message: result.error || "Insertion failed." };
901
+ }
902
+
903
+ } catch (error) {
904
+ const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during creation for model '${targetModel}'. Error: ${error.message}`;
905
+ logger.error(msg, error.stack);
906
+ return { success: false, message: msg };
907
+ }
908
+ }
909
+
910
+
911
+ /**
912
+ * Handles the 'UpdateData' workflow action.
913
+ * Finds document(s) based on a selector, substitutes variables in updates,
914
+ * validates, and updates the document(s) using the updateData function.
915
+ *
916
+ * @param {object} actionDef - The definition of the 'UpdateData' action.
917
+ * @param {object} contextData - The current workflow run context data.
918
+ * @param {object} user - The user object.
919
+ * @param {object} dbCollection - The MongoDB collection for the user (bien que updateData utilise getCollectionForUser).
920
+ * @returns {Promise<{success: boolean, message?: string, modifiedCount?: number, matchedCount?: number}>} - Result of the action.
921
+ */
922
+ async function handleUpdateDataAction(actionDef, contextData, user) {
923
+ const { targetModel, targetSelector, fieldsToUpdate, updateMultiple = false } = actionDef; // updateMultiple optionnel, défaut false
924
+
925
+ // 1. Basic Validation
926
+ if (!targetModel || typeof targetModel !== 'string') {
927
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
928
+ logger.error(msg);
929
+ return { success: false, message: msg };
930
+ }
931
+ if (!targetSelector) {
932
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
933
+ logger.error(msg);
934
+ return { success: false, message: msg };
935
+ }
936
+ if (!fieldsToUpdate) {
937
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'fieldsToUpdate'.`;
938
+ logger.error(msg);
939
+ return { success: false, message: msg };
940
+ }
941
+
942
+ logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Updating data for model '${targetModel}'. Multiple: ${updateMultiple}`);
943
+
944
+ try {
945
+ // 2. Substitute Variables in selector and updates
946
+ let substitutedSelectorString;
947
+ let substitutedUpdatesString;
948
+ let selectorObject;
949
+ let updatesObject;
950
+
951
+ // Substitute targetSelector (assuming it's a JSON string or object)
952
+ if (typeof targetSelector === 'string') {
953
+ substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
954
+ selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
955
+ } else if (typeof targetSelector === 'object') {
956
+ selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
957
+ } else {
958
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
959
+ logger.error(msg);
960
+ return { success: false, message: msg };
961
+ }
962
+
963
+ // Substitute fieldsToUpdate (assuming it's a JSON string or object)
964
+ if (typeof fieldsToUpdate === 'string') {
965
+ substitutedUpdatesString = await substituteVariables(fieldsToUpdate, contextData, user);
966
+ } else if (typeof fieldsToUpdate === 'object') {
967
+ updatesObject = await substituteVariables(fieldsToUpdate, contextData, user); // Substitute values within the object
968
+ } else {
969
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' has an invalid type (${typeof fieldsToUpdate}). Expected string (JSON) or object.`;
970
+ logger.error(msg);
971
+ return { success: false, message: msg };
972
+ }
973
+
974
+ // 3. Parse substituted JSON strings
975
+ if (substitutedSelectorString) {
976
+ try {
977
+ selectorObject = JSON.parse(substitutedSelectorString);
978
+ if (typeof selectorObject !== 'object' || selectorObject === null) {
979
+ throw new Error("Parsed selector is not a valid object.");
980
+ }
981
+ } catch (parseError) {
982
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
983
+ logger.error(msg);
984
+ return { success: false, message: msg };
985
+ }
986
+ }
987
+ if (substitutedUpdatesString) {
988
+ try {
989
+ updatesObject = JSON.parse(substitutedUpdatesString);
990
+ if (typeof updatesObject !== 'object' || updatesObject === null) {
991
+ throw new Error("Parsed updates is not a valid object.");
992
+ }
993
+ } catch (parseError) {
994
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'fieldsToUpdate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedUpdatesString}`;
995
+ logger.error(msg);
996
+ return { success: false, message: msg };
997
+ }
998
+ }
999
+
1000
+ // Remove system fields potentially included in updates by mistake
1001
+ delete updatesObject._id;
1002
+ delete updatesObject._model;
1003
+ delete updatesObject._user;
1004
+ delete updatesObject._hash;
1005
+
1006
+ if (Object.keys(updatesObject).length === 0) {
1007
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): 'fieldsToUpdate' resulted in an empty update object after substitution/parsing. Nothing to update.`;
1008
+ logger.warn(msg);
1009
+ return { success: true, message: "No fields to update.", modifiedCount: 0, matchedCount: 0 };
1010
+ }
1011
+
1012
+ const updateResult = await patchData(
1013
+ targetModel,
1014
+ selectorObject,
1015
+ updatesObject,
1016
+ {},
1017
+ user, false
1018
+ );
1019
+
1020
+ // 6. Return result
1021
+ if (updateResult.success || updateResult.unmodified) {
1022
+ logger.info(`[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Update successful for model '${targetModel}'. Matched: ${updateResult.matchedCount}, Modified: ${updateResult.modifiedCount}`);
1023
+ return {
1024
+ success: true,
1025
+ modifiedCount: updateResult.modifiedCount,
1026
+ matchedCount: updateResult.matchedCount,
1027
+ message: updateResult.message,
1028
+ updatedContext: {
1029
+ triggerData: {...contextData.triggerData || {}, ...updatesObject}
1030
+ }
1031
+ };
1032
+ } else {
1033
+ // updateData now throws errors, so this 'else' might not be reached often,
1034
+ // but kept for safety in case it returns { success: false } in some scenarios.
1035
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): updateData function reported failure. Message: ${updateResult.error}`;
1036
+ logger.error(msg);
1037
+ return { success: false, message: msg };
1038
+ }
1039
+
1040
+ } catch (error) {
1041
+ // Catch errors thrown by updateData (validation, permissions, DB errors) or other unexpected errors
1042
+ const msg = `[handleUpdateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during update for model '${targetModel}'. Error: ${error.message}`;
1043
+ logger.error(msg, error.stack);
1044
+ return { success: false, message: msg };
1045
+ }
1046
+ }
1047
+
1048
+
1049
+
1050
+ /**
1051
+ * Handles the 'DeleteData' workflow action.
1052
+ * Finds document(s) based on a selector, substitutes variables,
1053
+ * and deletes the document(s) using the deleteData function.
1054
+ *
1055
+ * @param {object} actionDef - The definition of the 'DeleteData' action.
1056
+ * @param {object} contextData - The current workflow run context data.
1057
+ * @param {object} user - The user object.
1058
+ * @param {object} dbCollection - The MongoDB collection for the user (bien que deleteData utilise getCollectionForUser).
1059
+ * @returns {Promise<{success: boolean, message?: string, deletedCount?: number}>} - Result of the action.
1060
+ */
1061
+ async function handleDeleteDataAction(actionDef, contextData, user, dbCollection) {
1062
+ // deleteMultiple optionnel, défaut false (supprime un seul par défaut)
1063
+ const { targetModel, targetSelector, deleteMultiple = false } = actionDef;
1064
+
1065
+ // 1. Basic Validation
1066
+ if (!targetModel || typeof targetModel !== 'string') {
1067
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing or invalid 'targetModel'.`;
1068
+ logger.error(msg);
1069
+ return { success: false, message: msg };
1070
+ }
1071
+ if (!targetSelector) {
1072
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Missing 'targetSelector'.`;
1073
+ logger.error(msg);
1074
+ return { success: false, message: msg };
1075
+ }
1076
+
1077
+ logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Deleting data for model '${targetModel}'. Multiple: ${deleteMultiple}`);
1078
+
1079
+ try {
1080
+ // 2. Substitute Variables in selector
1081
+ let substitutedSelectorString;
1082
+ let selectorObject;
1083
+
1084
+ // Substitute targetSelector (assuming it's a JSON string or object)
1085
+ if (typeof targetSelector === 'string') {
1086
+ substitutedSelectorString = await substituteVariables(targetSelector, contextData, user);
1087
+ } else if (typeof targetSelector === 'object') {
1088
+ selectorObject = await substituteVariables(targetSelector, contextData, user); // Substitute values within the object
1089
+ } else {
1090
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): 'targetSelector' has an invalid type (${typeof targetSelector}). Expected string (JSON) or object.`;
1091
+ logger.error(msg);
1092
+ return { success: false, message: msg };
1093
+ }
1094
+
1095
+ // 3. Parse substituted JSON string
1096
+ if (substitutedSelectorString) {
1097
+ try {
1098
+ selectorObject = JSON.parse(substitutedSelectorString);
1099
+ if (typeof selectorObject !== 'object' || selectorObject === null) {
1100
+ throw new Error("Parsed selector is not a valid object.");
1101
+ }
1102
+ } catch (parseError) {
1103
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'targetSelector' JSON. Error: ${parseError.message}. Substituted string: ${substitutedSelectorString}`;
1104
+ logger.error(msg);
1105
+ return { success: false, message: msg };
1106
+ }
1107
+ }
1108
+
1109
+ // 5. Call the centralized deleteData function créer dans data.js)
1110
+ // Cette fonction devra gérer la recherche préalable pour les workflows 'DataDeleted' et la suppression des fichiers.
1111
+ const deleteResult = await deleteData(
1112
+ targetModel,
1113
+ selectorObject,
1114
+ user
1115
+ );
1116
+
1117
+ // 6. Return result
1118
+ if (deleteResult.success) {
1119
+ logger.info(`[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Delete successful for model '${targetModel}'. Deleted: ${deleteResult.deletedCount}`);
1120
+ return {
1121
+ success: true,
1122
+ deletedCount: deleteResult.deletedCount,
1123
+ message: deleteResult.message // Pass along messages like "not found"
1124
+ };
1125
+ } else {
1126
+ // deleteData devrait lancer des erreurs, mais on garde ce else par sécurité.
1127
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): deleteData function reported failure. Message: ${deleteResult.message}`;
1128
+ logger.error(msg);
1129
+ return { success: false, message: msg };
1130
+ }
1131
+
1132
+ } catch (error) {
1133
+ // Catch errors thrown by deleteData (permissions, DB errors) or other unexpected errors
1134
+ const msg = `[handleDeleteDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during deletion for model '${targetModel}'. Error: ${error.message}`;
1135
+ logger.error(msg, error.stack);
1136
+ return { success: false, message: msg };
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * Handles the 'ExecuteServiceFunction' workflow action.
1142
+ * Acts as a secure bridge between the workflow engine and native service modules.
1143
+ *
1144
+ * @param {object} actionDef - The action definition.
1145
+ * @param {object} contextData - The current workflow context.
1146
+ * @param {object} user - The user object.
1147
+ * @returns {Promise<{success: boolean, message?: string, updatedContext?: object}>}
1148
+ */
1149
+ async function handleExecuteServiceFunction(actionDef, contextData, user) {
1150
+ const { serviceName, functionName, args: argsTemplate } = actionDef;
1151
+
1152
+ if (!serviceName || !functionName) {
1153
+ return { success: false, message: "Action requires 'serviceName' and 'functionName'." };
1154
+ }
1155
+
1156
+ const service = services[serviceName];
1157
+ if (!service) {
1158
+ return { success: false, message: `Service '${serviceName}' not found in the registry.` };
1159
+ }
1160
+
1161
+ const func = service[functionName];
1162
+ if (typeof func !== 'function') {
1163
+ return { success: false, message: `Function '${functionName}' not found in service '${serviceName}'.` };
1164
+ }
1165
+
1166
+ try {
1167
+ // Substitute variables in the arguments array
1168
+ const substitutedArgs = Array.isArray(argsTemplate)
1169
+ ? await substituteVariables(argsTemplate, contextData, user)
1170
+ : [];
1171
+
1172
+ logger.info(`[Service Call] Calling ${serviceName}.${functionName} with ${substitutedArgs.length} argument(s).`);
1173
+ const result = await func(...substitutedArgs, user);
1174
+
1175
+ return {
1176
+ success: true,
1177
+ updatedContext: { serviceResult: result } // Store result in context
1178
+ };
1179
+ } catch (error) {
1180
+ const msg = `Error executing ${serviceName}.${functionName}: ${error.message}`;
1181
+ logger.error(`[Service Call] ${msg}`, error.stack);
1182
+ return { success: false, message: msg };
1183
+ }
1184
+ }
1185
+
1186
+ // Dans workflow.js
1187
+ export async function executeStepAction(actionDef, contextData, user, dbCollection) {
1188
+ logger.info(`[executeStepAction] Executing action type ${actionDef.type} for action ${actionDef._id} (${actionDef.name})`);
1189
+
1190
+ try {
1191
+ let result;
1192
+ switch (actionDef.type) {
1193
+ case 'Log':
1194
+ logger.info(`[Workflow Log Action] Action: ${actionDef.name}. Contexte:`, contextData);
1195
+ result = { success: true, message: 'Log action executed successfully.' }; // <--- CORRECTION
1196
+ break;
1197
+ case 'HttpRequest':
1198
+ result = await handleHttpRequestAction(actionDef, contextData, user, dbCollection);
1199
+ break;
1200
+ case 'CreateData':
1201
+ result = await handleCreateDataAction(actionDef, contextData, user, dbCollection);
1202
+ break;
1203
+ case 'UpdateData':
1204
+ result = await handleUpdateDataAction(actionDef, contextData, user);
1205
+ break;
1206
+ case 'DeleteData':
1207
+ result = await handleDeleteDataAction(actionDef, contextData, user, dbCollection);
1208
+ break;
1209
+ case 'GenerateAIContent':
1210
+ result = await executeGenerateAIContentAction(actionDef, contextData, user);
1211
+ break;
1212
+ case 'SendEmail':
1213
+ result = await handleSendEmailAction(actionDef, contextData, user);
1214
+ break;
1215
+ case 'Wait':
1216
+ result = await handleWaitAction(actionDef, contextData, user);
1217
+ break;
1218
+ case 'ExecuteScript':
1219
+ result = await executeSafeJavascript(actionDef, contextData, user);
1220
+ break;
1221
+ case 'ExecuteServiceFunction':
1222
+ result = await handleExecuteServiceFunction(actionDef, contextData, user);
1223
+ break;
1224
+ default:
1225
+ logger.error(`[executeStepAction] Unknown action type: ${actionDef.type}`);
1226
+ return { success: false, message: `Unknown action type: ${actionDef.type}` };
1227
+ }
1228
+ return result;
1229
+ } catch (error) {
1230
+ logger.error(`[executeStepAction] Error executing action ${actionDef.name} (${actionDef._id}): ${error.message}`, error.stack);
1231
+ return { success: false, message: error.message || 'Action execution failed' };
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Résout un chemin de variable complexe (ex: "triggerData.order.customer.contact.email")
1237
+ * en construisant un pipeline d'agrégation dynamique pour tout récupérer en une seule requête.
1238
+ *
1239
+ * @param {string} pathString - Le chemin de la variable, ex: "triggerData.order.customer.contact.email".
1240
+ * @param {object} initialContext - L'objet de départ (le triggerData).
1241
+ * @param {object} user - L'objet utilisateur pour les requêtes DB.
1242
+ * @returns {Promise<any>} La valeur résolue.
1243
+ */
1244
+ async function resolvePathValue(pathString, initialContext, user) {
1245
+ const pathParts = pathString.split('.');
1246
+ const rootObjectKey = pathParts.shift(); // ex: "triggerData"
1247
+
1248
+ // Si le chemin ne commence pas par triggerData ou context, essayer de résoudre directement
1249
+ if (rootObjectKey !== 'triggerData' && rootObjectKey !== 'context') {
1250
+ let current = initialContext;
1251
+ for (const part of [rootObjectKey, ...pathParts]) {
1252
+ if (current === null || typeof current === 'undefined') return undefined;
1253
+ current = current[part];
1254
+ }
1255
+ return current;
1256
+ }
1257
+
1258
+ // Vérifier si c'est un chemin simple qui peut être résolu sans aggregation
1259
+ if (pathParts.length === 1) {
1260
+ return initialContext[pathParts[0]];
1261
+ }
1262
+
1263
+ let currentModelName = initialContext._model;
1264
+ let currentDocId = new ObjectId(initialContext._id);
1265
+ const collection = await getCollectionForUser(user);
1266
+
1267
+ // Construire le pipeline d'agrégation
1268
+ const pipeline = [
1269
+ { $match: { _id: currentDocId } }
1270
+ ];
1271
+
1272
+ // Itérer sur chaque segment du chemin pour construire les lookups
1273
+ for (let i = 0; i < pathParts.length; i++) {
1274
+ const segment = pathParts[i];
1275
+
1276
+ // Si c'est le dernier segment, on n'a pas besoin de faire un lookup
1277
+ if (i === pathParts.length - 1) break;
1278
+
1279
+ const modelDef = await getModel(currentModelName, user);
1280
+ const fieldDef = modelDef.fields.find(f => f.name === segment);
1281
+
1282
+ if (!fieldDef || fieldDef.type !== 'relation') {
1283
+ // Si ce n'est pas une relation, on ne peut pas continuer le chemin
1284
+ return undefined;
1285
+ }
1286
+
1287
+ const nextModelName = fieldDef.relation;
1288
+ const asField = `__resolved_${segment}`;
1289
+
1290
+ pipeline.push({
1291
+ $lookup: {
1292
+ from: collection.collectionName,
1293
+ let: { relationId: `$${segment}` },
1294
+ pipeline: [
1295
+ {
1296
+ $match: {
1297
+ $expr: {
1298
+ $eq: ["$_id", {
1299
+ $cond: {
1300
+ if: { $eq: [{ $type: "$$relationId" }, "string"] },
1301
+ then: { $toObjectId: "$$relationId" },
1302
+ else: "$$relationId"
1303
+ }
1304
+ }]
1305
+ }
1306
+ }
1307
+ }
1308
+ ],
1309
+ as: asField
1310
+ }
1311
+ });
1312
+
1313
+ pipeline.push({
1314
+ $unwind: {
1315
+ path: `$${asField}`,
1316
+ preserveNullAndEmptyArrays: true
1317
+ }
1318
+ });
1319
+
1320
+ pipeline.push({
1321
+ $addFields: {
1322
+ [segment]: `$${asField}`
1323
+ }
1324
+ });
1325
+
1326
+ pipeline.push({ $project: { [asField]: 0 } });
1327
+
1328
+ currentModelName = nextModelName;
1329
+ }
1330
+
1331
+ const results = await collection.aggregate(pipeline).toArray();
1332
+
1333
+ if (results.length === 0) {
1334
+ return undefined;
1335
+ }
1336
+
1337
+ // Extraire la valeur finale
1338
+ let finalValue = results[0];
1339
+ for (const part of pathParts) {
1340
+ if (finalValue === null || typeof finalValue === 'undefined') {
1341
+ return undefined;
1342
+ }
1343
+ finalValue = finalValue[part];
1344
+ }
1345
+
1346
+ return finalValue;
1347
+ }
1348
+
1349
+ /**
1350
+ * Triggers the instantiation of a workflowRun if conditions are met.
1351
+ * Checks the event type and trigger's data filter.
1352
+ * Creates a 'workflowRun' document for later asynchronous execution.
1353
+ *
1354
+ * @param {object} triggerData - The data that triggered the workflow(s) (can be a data document or model document).
1355
+ * @param {object} user - The associated user.
1356
+ * @param {'DataAdded' | 'DataEdited' | 'DataDeleted' | 'ModelAdded' | 'ModelEdited' | 'ModelDeleted'} eventType - The event type.
1357
+ */
1358
+ export async function triggerWorkflows(triggerData, user, eventType) {
1359
+ const trigger = async (triggerData, user, eventType) => {
1360
+ // Basic validation
1361
+ if (!triggerData || !user || !eventType) {
1362
+ console.warn("triggerWorkflows: Invalid call - missing triggerData, user, or eventType.", {
1363
+ hasTriggerData: !!triggerData,
1364
+ hasUser: !!user,
1365
+ eventType
1366
+ });
1367
+ return;
1368
+ }
1369
+
1370
+ // Determine model name and data ID based on event type
1371
+ const targetModelName = eventType.startsWith('Model') ? triggerData.name : triggerData._model;
1372
+ const dataId = eventType.startsWith('Model') ? null : triggerData._id;
1373
+
1374
+ if (!targetModelName) {
1375
+ console.warn(`triggerWorkflows: Cannot determine model name for event ${eventType}.`, triggerData);
1376
+ return;
1377
+ }
1378
+
1379
+ console.log(`[Workflow Trigger] Event: ${eventType}, Model: ${targetModelName}${dataId ? `, Data ID: ${dataId}` : ''}, User: ${user.username}`);
1380
+
1381
+ try {
1382
+ const dbCollection = await getCollectionForUser(user);
1383
+
1384
+ // 1. Find relevant WorkflowTriggers
1385
+ const workflowTriggers = await dbCollection.find({
1386
+ _model: 'workflowTrigger',
1387
+ targetModel: targetModelName,
1388
+ isActive: true,
1389
+ onEvent: eventType,
1390
+ $or: [{_user: user._user}, {_user: user.username}]
1391
+ }).toArray();
1392
+
1393
+ if (workflowTriggers.length === 0) {
1394
+ console.debug(`[Workflow Trigger] No active triggers found for ${targetModelName}/${eventType}.`);
1395
+ return;
1396
+ }
1397
+ console.debug(`[Workflow Trigger] Found ${workflowTriggers.length} potential trigger(s) for ${targetModelName}/${eventType}.`);
1398
+
1399
+ // 2. For each trigger, verify data filter and create workflowRun
1400
+ for (const trigger of workflowTriggers) {
1401
+ console.debug(`[Workflow Trigger] Evaluating trigger ${trigger._id} (${trigger.name || 'Unnamed'})...`);
1402
+
1403
+ // 3. Check data filter if applicable
1404
+ if (eventType.startsWith('Data') && trigger.dataFilter) {
1405
+ let dataFilterCondition = null;
1406
+ try {
1407
+ // dataFilter is expected to be stored as an object or valid JSON string
1408
+ if (typeof trigger.dataFilter === 'string') {
1409
+ dataFilterCondition = JSON.parse(trigger.dataFilter);
1410
+ } else if (typeof trigger.dataFilter === 'object' && trigger.dataFilter !== null) {
1411
+ dataFilterCondition = trigger.dataFilter;
1412
+ }
1413
+ } catch (parseError) {
1414
+ console.error(`[Workflow Trigger] JSON parsing error for dataFilter in trigger ${trigger._id}:`, parseError);
1415
+ continue; // Skip to next trigger if filter is invalid
1416
+ }
1417
+
1418
+ try {
1419
+ const mod = await getModel(targetModelName, user);
1420
+ const filterMatches = isConditionMet(mod, dataFilterCondition, triggerData, [], user);
1421
+
1422
+ if (!filterMatches) {
1423
+ console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter not satisfied by data. Skipping workflowRun creation.`);
1424
+ continue;
1425
+ }
1426
+ console.debug(`[Workflow Trigger] Trigger ${trigger._id}: dataFilter satisfied.`);
1427
+ } catch (filterError) {
1428
+ console.error(`[Workflow Trigger] Error evaluating dataFilter for trigger ${trigger._id}:`, filterError);
1429
+ continue;
1430
+ }
1431
+ }
1432
+
1433
+ // 4. If filters passed, create workflowRun instance
1434
+ if (!trigger.workflow || !isObjectId(trigger.workflow)) {
1435
+ console.warn(`[Workflow Trigger] Trigger ${trigger._id} has no valid associated workflow.`);
1436
+ continue;
1437
+ }
1438
+
1439
+ // a. Verify workflow exists
1440
+ const workflowDefinition = await dbCollection.findOne({
1441
+ _id: new ObjectId(trigger.workflow),
1442
+ _model: 'workflow',
1443
+ $or: [{_user: user._user}, {_user: user.username}]
1444
+ });
1445
+
1446
+ if (!workflowDefinition) {
1447
+ console.warn(`[Workflow Trigger] Workflow ${trigger.workflow} associated with trigger ${trigger._id} not found.`);
1448
+ continue;
1449
+ }
1450
+
1451
+ // b. Create workflowRun document
1452
+ const workflowRunData = {
1453
+ _model: 'workflowRun',
1454
+ _user: user._user || user.username,
1455
+ workflow: workflowDefinition._id,
1456
+ contextData: {
1457
+ triggerDataModel: targetModelName,
1458
+ triggerData: triggerData
1459
+ },
1460
+ status: 'pending',
1461
+ owner: null,
1462
+ startedAt: new Date()
1463
+ };
1464
+
1465
+ try {
1466
+ const insertResult = await dbCollection.insertOne(workflowRunData);
1467
+ if (insertResult.insertedId) {
1468
+ console.info(`[Workflow Trigger] Created workflowRun ${insertResult.insertedId} for workflow ${workflowDefinition.name} (ID: ${workflowDefinition._id}) triggered by ${trigger._id}.`);
1469
+ await workflowModule.processWorkflowRun(insertResult.insertedId, user);
1470
+ } else {
1471
+ console.error(`[Workflow Trigger] Failed to create workflowRun for workflow ${workflowDefinition._id} (Trigger: ${trigger._id}).`);
1472
+ }
1473
+ } catch (insertError) {
1474
+ console.error(`[Workflow Trigger] Error creating workflowRun for workflow ${workflowDefinition._id} (Trigger: ${trigger._id}):`, insertError);
1475
+ }
1476
+ }
1477
+ } catch (error) {
1478
+ console.error(`[Workflow Trigger] General error in triggerWorkflows for ${targetModelName}${dataId ? ` ID: ${dataId}` : ''} (Event: ${eventType}):`, error);
1479
+ }
1480
+ }
1481
+
1482
+ return trigger(triggerData, user, eventType);
1483
+ }
1484
+ /**
1485
+ * Processes a workflowRun instance step-by-step.
1486
+ * Fetches the run, evaluates conditions, executes actions, and transitions
1487
+ * to the next step based on success or failure, updating the workflowRun status.
1488
+ *
1489
+ * @param {string|ObjectId} workflowRunId - The ID of the workflowRun to process.
1490
+ * @param {object} user - The user context for database access.
1491
+ * @returns {Promise<void>}
1492
+ */
1493
+
1494
+ export async function processWorkflowRun(workflowRunId, user) {
1495
+ const dbCollection = await getCollectionForUser(user);
1496
+ const runId = typeof workflowRunId === 'string' ? new ObjectId(workflowRunId) : workflowRunId;
1497
+
1498
+ logger.info(`[processWorkflowRun] Starting processing for workflowRun ID: ${runId}`);
1499
+
1500
+ let currentRunState;
1501
+ let contextData = {};
1502
+ let stepExecutionsCount = {};
1503
+ let history = [];
1504
+
1505
+ try {
1506
+ currentRunState = await dbCollection.findOne({ _id: runId, _model: 'workflowRun' });
1507
+
1508
+ if (!currentRunState) {
1509
+ logger.error(`[processWorkflowRun] WorkflowRun ID: ${runId} not found.`);
1510
+ return;
1511
+ }
1512
+
1513
+ stepExecutionsCount = currentRunState.stepExecutionsCount || {};
1514
+ history = currentRunState.history || [];
1515
+ if (['completed', 'failed', 'cancelled'].includes(currentRunState.status)) {
1516
+ logger.info(`[processWorkflowRun] WorkflowRun ID: ${runId} is already in a terminal state (${currentRunState.status}). Skipping.`);
1517
+ return;
1518
+ }
1519
+
1520
+ const logError = async (error) => {
1521
+ logger.error(error);
1522
+ await dbCollection.updateOne(
1523
+ { _id: runId },
1524
+ { $set: { status: 'failed', error, completedAt: new Date(), stepExecutionsCount, history } }
1525
+ );
1526
+ };
1527
+
1528
+ const workflowDefinition = await dbCollection.findOne({ _id: new ObjectId(currentRunState.workflow), _model: 'workflow' });
1529
+ if (!workflowDefinition) {
1530
+ return await logError(`Workflow definition ID: ${currentRunState.workflow} not found.`);
1531
+ }
1532
+
1533
+ contextData = currentRunState.contextData || {};
1534
+ let currentStepId = currentRunState.currentStep || workflowDefinition.startStep;
1535
+
1536
+ if (!currentStepId || !isObjectId(currentStepId)) {
1537
+ const finalStatus = workflowDefinition.startStep ? 'failed' : 'completed';
1538
+ const errorMessage = workflowDefinition.startStep ? 'No valid starting step defined in workflow or run state.' : null;
1539
+ await dbCollection.updateOne(
1540
+ { _id: runId },
1541
+ { $set: { status: finalStatus, error: errorMessage, completedAt: new Date(), currentStep: null, stepExecutionsCount, history } }
1542
+ );
1543
+ return;
1544
+ }
1545
+
1546
+ let stepCount = 0;
1547
+ let lastStepId = null;
1548
+ const mw = Config.Get('maxWorkflowSteps', maxWorkflowSteps);
1549
+ while (currentStepId) {
1550
+ if (stepCount++ >= mw) {
1551
+ return await logError(`Maximum workflow step executions exceeded (${mw} max).`);
1552
+ }
1553
+
1554
+ const execCount = (stepExecutionsCount[currentStepId] || 0) + 1;
1555
+ const m = Config.Get('maxExecutionsByStep', maxExecutionsByStep);
1556
+ if (execCount > m) {
1557
+ return await logError(`Maximum executions (${m}) exceeded for step ${currentStepId}.`);
1558
+ }
1559
+ stepExecutionsCount[currentStepId] = execCount;
1560
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Current Step ID: ${currentStepId}`);
1561
+
1562
+ const currentStepDef = await dbCollection.findOne({ _id: new ObjectId(currentStepId), _model: 'workflowStep' });
1563
+ if (!currentStepDef) {
1564
+ return await logError(`Step definition ID: ${currentStepId} not found.`);
1565
+ }
1566
+
1567
+ const stepHistoryEntry = {
1568
+ stepId: currentStepId.toString(),
1569
+ stepName: currentStepDef.name,
1570
+ executedAt: new Date(),
1571
+ status: 'pending',
1572
+ actions: []
1573
+ };
1574
+
1575
+ let stepSucceeded = true;
1576
+ let logInfo = null;
1577
+ let conditionsMet = true;
1578
+
1579
+ try {
1580
+ // Add logging to see the actual pipeline being executed
1581
+ logger.debug('Executing pipeline:', JSON.stringify(await substituteVariables(currentStepDef.conditions, contextData, user), null, 2));
1582
+
1583
+ // And log the context data to verify processedChunk exists
1584
+ logger.debug('Context data:', JSON.stringify(contextData, null, 2));
1585
+
1586
+ // --- 7. Évaluation des conditions de l'étape ---
1587
+ if (currentStepDef.conditions && Object.keys(currentStepDef.conditions).length > 0) {
1588
+ const substitutedConditions = await substituteVariables(currentStepDef.conditions, contextData, user);
1589
+ // Si un modèle est spécifié dans le contexte, la condition est une requête sur la base de données.
1590
+ if (contextData.triggerDataModel) {
1591
+ const searchResult = await searchData({ model: contextData.triggerDataModel, filter: substitutedConditions, limit: 1 }, user);
1592
+ conditionsMet = searchResult && searchResult.count > 0;
1593
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: DB condition evaluated. Found ${searchResult ? searchResult.count : 0} match(es). Result: ${conditionsMet}`);
1594
+ } else {
1595
+ console.log({substitutedConditions, c:contextData['triggerData']['event']['type']});
1596
+ // Si aucun modèle n'est spécifié (ex: webhook), la condition est évaluée sur l'objet de contexte lui-même.
1597
+ conditionsMet = isConditionMet(null, substitutedConditions, contextData, [], user);
1598
+
1599
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Context condition evaluated. Operator: ${JSON.stringify(substitutedConditions)}, Result: ${conditionsMet}`);
1600
+ }
1601
+ }
1602
+
1603
+ // --- 8. Exécution des actions si les conditions sont remplies ---
1604
+ if (conditionsMet) {
1605
+ if (currentStepDef.actions && currentStepDef.actions.length > 0) {
1606
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Executing ${currentStepDef.actions.length} action(s)...`);
1607
+ for (const actionId of currentStepDef.actions) {
1608
+ if (!isObjectId(actionId)) continue;
1609
+ const actionDef = await dbCollection.findOne({ _id: new ObjectId(actionId), _model: 'workflowAction' });
1610
+ const actionHistoryEntry = {
1611
+ actionId: actionId.toString(),
1612
+ actionName: actionDef.name,
1613
+ actionType: actionDef.type,
1614
+ executedAt: new Date(),
1615
+ status: 'pending'
1616
+ };
1617
+
1618
+ if (!actionDef) return await logError(`Action definition ${actionId} not found.`);
1619
+ const actionResult = await workflowModule.executeStepAction(actionDef, contextData, user, dbCollection);
1620
+
1621
+ if (actionResult.status === 'paused') {
1622
+ // L'action demande une pause !
1623
+ const { duration, durationUnit } = actionResult;
1624
+ const now = new Date();
1625
+ let resumeAt = new Date(now);
1626
+
1627
+ // Calculer la date de reprise
1628
+ const ms = { seconds: 1000, minutes: 60000, hours: 3600000, days: 86400000 };
1629
+ resumeAt.setTime(now.getTime() + (duration * ms[durationUnit]));
1630
+
1631
+ logger.info(`[processWorkflowRun] Run ID: ${runId} is pausing. Will resume at: ${resumeAt.toISOString()}`);
1632
+
1633
+ // Mettre à jour le workflowRun avec le statut 'paused' et la date de reprise
1634
+ await dbCollection.updateOne({ _id: runId }, {
1635
+ $set: {
1636
+ status: 'paused',
1637
+ currentStep: currentStepDef.onSuccessStep, // On prépare la prochaine étape
1638
+ contextData,
1639
+ log: actionResult.message
1640
+ }
1641
+ });
1642
+
1643
+ // Planifier le réveil du workflow
1644
+ schedule.scheduleJob(resumeAt, async () => {
1645
+ logger.info(`[Scheduler] Waking up paused workflowRun ID: ${runId}`);
1646
+ // On relance le traitement pour ce workflow spécifique
1647
+ await workflowModule.processWorkflowRun(runId, user);
1648
+ });
1649
+
1650
+ // Arrêter le traitement actuel de cette exécution
1651
+ return; // Très important de stopper la boucle ici
1652
+ }
1653
+ if (!actionResult.success) {
1654
+ stepSucceeded = false;
1655
+ logInfo = actionResult.message || `Action ${actionDef.name || actionId} failed.`;
1656
+ actionHistoryEntry.status = 'failed';
1657
+ actionHistoryEntry.result = logInfo;
1658
+ stepHistoryEntry.actions.push(actionHistoryEntry);
1659
+ break;
1660
+ } else if (actionResult.logs && actionResult.logs.length > 0) {
1661
+ // Ajouter les logs du script à l'historique de l'action
1662
+ actionHistoryEntry.logs = actionResult.logs;
1663
+ logger.info(`[processWorkflowRun] Script logs for action ${actionDef.name}:`, actionResult.logs);
1664
+
1665
+ }else{
1666
+ logInfo = `Action ${actionDef.name || actionId} : ${actionResult.message}`;
1667
+ actionHistoryEntry.status = 'success';
1668
+ actionHistoryEntry.result = actionResult.message;
1669
+ stepHistoryEntry.actions.push(actionHistoryEntry);
1670
+ }
1671
+ if (actionResult.updatedContext) {
1672
+ contextData = { ...contextData, ...actionResult.updatedContext };
1673
+ }
1674
+ //console.log("action", util.inspect(actionResult, false, 8, true));
1675
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}, Action ID: ${actionId}: Executed successfully.`);
1676
+ logger.info(logInfo);
1677
+ }
1678
+ }
1679
+ if (stepSucceeded) {
1680
+ stepHistoryEntry.status = 'success';
1681
+ stepHistoryEntry.result = 'Step actions completed successfully.';
1682
+ }
1683
+ } else {
1684
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Conditions not met. Skipping actions.`);
1685
+ }
1686
+ } catch (error) {
1687
+ logger.error(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Error during condition/action execution: ${error.message}`);
1688
+ stepSucceeded = false;
1689
+ logInfo = error.message;
1690
+ stepHistoryEntry.status = 'failed';
1691
+ stepHistoryEntry.result = logInfo;
1692
+ }
1693
+
1694
+ // --- 9. Détermination de la prochaine étape ---
1695
+ let nextStepId = null;
1696
+ let finalStatusForRun = null;
1697
+
1698
+ if (stepSucceeded && conditionsMet) {
1699
+ // CHEMIN SUCCÈS : Les conditions sont remplies et les actions ont réussi.
1700
+ logger.info(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Step path succeeded.`);
1701
+ nextStepId = currentStepDef.onSuccessStep;
1702
+ if (currentStepDef.isTerminal || !nextStepId) {
1703
+ finalStatusForRun = 'completed';
1704
+ nextStepId = null;
1705
+ }
1706
+ } else {
1707
+ // CHEMIN ÉCHEC/BRANCHE : Une action a échoué OU les conditions n'ont pas été remplies.
1708
+ const reason = logInfo ? `Action failed: ${logInfo}` : 'Step conditions not met.';
1709
+ logger.warn(`[processWorkflowRun] Run ID: ${runId}, Step ID: ${currentStepId}: Taking failure/branching path. Reason: ${reason}`);
1710
+ nextStepId = currentStepDef.onFailureStep;
1711
+
1712
+ if (!nextStepId || !isObjectId(nextStepId)) {
1713
+ // Fin du workflow. Le statut est 'failed' seulement si une vraie erreur s'est produite.
1714
+ finalStatusForRun = logInfo ? 'failed' : 'completed';
1715
+ nextStepId = null;
1716
+ }
1717
+ }
1718
+
1719
+ // --- 10. Mise à jour de l'état de l'exécution ---
1720
+ currentStepId = nextStepId;
1721
+ const updatePayload = { contextData, stepExecutionsCount }; // <-- CORRECTION: Always include stepExecutionsCount
1722
+
1723
+ if (finalStatusForRun) {
1724
+ updatePayload.status = finalStatusForRun;
1725
+ updatePayload.completedAt = new Date();
1726
+ updatePayload.currentStep = null;
1727
+ updatePayload.log = logInfo;
1728
+ } else {
1729
+ updatePayload.currentStep = currentStepId;
1730
+ }
1731
+
1732
+ // Update the last history entry with the final status and actions
1733
+ // We use a pipeline to reliably update the last element of the history array.
1734
+ // --- FIX ---
1735
+ // The previous logic using $slice failed when the history array had only one element.
1736
+ // This new logic uses a direct update on the last element of the array, which is more robust.
1737
+ await dbCollection.updateOne(
1738
+ { _id: runId }, {
1739
+ $set: updatePayload,
1740
+ $push: {
1741
+ history: stepHistoryEntry
1742
+ }
1743
+ }
1744
+ );
1745
+
1746
+
1747
+ if(finalStatusForRun) {
1748
+ logger.info(`[processWorkflowRun] Finished processing for workflowRun ID: ${runId}. Final Status: ${finalStatusForRun}`);
1749
+ }
1750
+
1751
+ lastStepId = currentStepId;
1752
+ }
1753
+ } catch (error) {
1754
+ logger.error(`[processWorkflowRun] Critical error during processing of workflowRun ID: ${runId}. Error: ${error.message}`, error.stack);
1755
+ await dbCollection.updateOne(
1756
+ { _id: runId, status: { $nin: ['completed', 'failed', 'cancelled'] } },
1757
+ { $set: { status: 'failed', log: `Critical error: ${error.message}`, completedAt: new Date(), stepExecutionsCount, history } }
1758
+ );
1759
+ }
1760
+ }
1761
+ /**
1762
+ * Executes an AI content generation action ('GenerateAIContent').
1763
+ * Retrieves the API key (prioritizing the user's environment), initializes a LangChain client,
1764
+ * formats a prompt with context data, calls the LLM, and returns the result
1765
+ * to be added to the workflow context.
1766
+ *
1767
+ * @param {object} action - The action definition from the workflow.
1768
+ * @param {object} context - The current workflow execution context.
1769
+ * @param {object} user - The user executing the workflow.
1770
+ * @returns {Promise<{success: boolean, updatedContext?: object, message?: string}>}
1771
+ */
1772
+ async function executeGenerateAIContentAction(action, context, user) {
1773
+ const { aiProvider, aiModel, prompt } = action;
1774
+
1775
+ // 1. Retrieve the API key (User Environment > Machine Environment)
1776
+ let apiKey;
1777
+
1778
+ const envKeyName = providers[aiProvider].key;
1779
+ if( !envKeyName ) {
1780
+ return {success: false, message: i18n.t('aiContent.env', `API key for provider ${aiProvider} (${envKeyName}) not found in user environment.`)};
1781
+ }
1782
+
1783
+ // First look in the user's environment variables
1784
+ const envCollection = await getCollectionForUser(user);
1785
+ const userEnvVar = await envCollection.findOne({ _model: 'env', name: envKeyName, _user: user.username });
1786
+
1787
+ if (userEnvVar && userEnvVar.value) {
1788
+ apiKey = userEnvVar.value;
1789
+ logger.debug(`[AI Action] Using user environment API key for ${aiProvider}.`);
1790
+ } else {
1791
+ apiKey = process.env[envKeyName];
1792
+ logger.debug(`[AI Action] Using machine environment API key for ${aiProvider}.`);
1793
+ }
1794
+
1795
+ if (!apiKey) {
1796
+ const message = `API key for ${aiProvider} (${envKeyName}) not found in user or machine environment.`;
1797
+ logger.error(`[AI Action] ${message}`);
1798
+ return { success: false, message };
1799
+ }
1800
+
1801
+ // 2. Initialize the LLM client with LangChain
1802
+ let llm = await getAIProvider(aiProvider, aiModel, apiKey);
1803
+ if( !llm ) {
1804
+ const message = `Failed to initialize AI client for ${aiProvider}: ${initError.message}`;
1805
+ logger.error(`[AI Action] ${message}`);
1806
+ return { success: false, message };
1807
+ }
1808
+
1809
+ try {
1810
+ const substitutedPrompt = await substituteVariables(prompt, context, user);
1811
+ // 3. Create the "Prompt Template"
1812
+ // LangChain handles variable substitution like {triggerData.name}
1813
+ const realPrompt = ChatPromptTemplate.fromTemplate(substitutedPrompt);
1814
+
1815
+ // 4. Create the processing chain (Prompt + Model)
1816
+ const chain = realPrompt.pipe(llm);
1817
+
1818
+ // 5. Invoke the chain with the complete context
1819
+ // LangChain will automatically replace placeholders in the prompt.
1820
+ logger.debug(`[AI Action] Invoking AI with model ${aiModel}.`);
1821
+ const response = await chain.invoke(context);
1822
+
1823
+ // 6. Prepare the result to be merged into the workflow context
1824
+ const llmOutput = response.content;
1825
+ const outputVariable = 'aiContent';
1826
+ const updatedContext = {
1827
+ [outputVariable]: llmOutput
1828
+ };
1829
+
1830
+ logger.info(`[AI Action] Content generated successfully and stored in context variable '${outputVariable}'.`);
1831
+
1832
+ return {
1833
+ success: true,
1834
+ updatedContext // This object will be merged into the main context by the workflow engine
1835
+ };
1836
+
1837
+ } catch (llmError) {
1838
+ const message = `Error during AI content generation with ${aiProvider}: ${llmError.message}`;
1839
+ logger.error(`[AI Action] ${message}`, llmError.stack);
1840
+ return { success: false, message };
1841
+ }
1842
+ }
1843
+
1844
+ /**
1845
+ * Gère l'action d'envoi d'e-mail d'un workflow.
1846
+ * Cette version améliorée peut traiter une liste de destinataires, en envoyant un e-mail
1847
+ * individuel et personnalisé à chacun. Elle gère les placeholders dans le sujet et le corps
1848
+ * de l'e-mail en se basant sur le contexte de chaque destinataire.
1849
+ *
1850
+ * @param {object} action - La définition de l'action 'SendEmail'.
1851
+ * @param {object} contextData - Le contexte d'exécution actuel du workflow.
1852
+ * @param {object} user - L'utilisateur propriétaire du workflow.
1853
+ * @returns {Promise<{success: boolean, message: string, data?: {sent: string[], failed: any[]}}>}
1854
+ */
1855
+ async function handleSendEmailAction(action, contextData, user) {
1856
+ logger.info(`[handleSendEmailAction] Executing for user ${user.username}.`);
1857
+
1858
+ // 1. Récupérer la configuration SMTP depuis le modèle 'env' de l'utilisateur
1859
+ const smtpConfig = await getSmtpConfig(user);
1860
+
1861
+ // 2. Valider la configuration de l'action
1862
+ const { emailRecipients, emailSubject, emailContent } = action;
1863
+ if (!emailRecipients || !emailSubject || !emailContent) {
1864
+ const msg = "SendEmail action is incomplete. 'emailRecipients', 'emailSubject', and 'emailContent' are required.";
1865
+ logger.error(`[handleSendEmailAction] ${msg}`);
1866
+ return { success: false, message: msg };
1867
+ }
1868
+
1869
+ try {
1870
+ // 3. Résoudre la liste des destinataires. Peut être un placeholder qui retourne un tableau.
1871
+ let resolvedRecipients = await substituteVariables(emailRecipients, contextData, user);
1872
+
1873
+ // S'assurer que nous avons toujours un tableau à parcourir
1874
+ if (!Array.isArray(resolvedRecipients)) {
1875
+ resolvedRecipients = [resolvedRecipients];
1876
+ }
1877
+
1878
+ resolvedRecipients = resolvedRecipients.flat();
1879
+
1880
+ if (resolvedRecipients.length === 0) {
1881
+ logger.error(`resolvedRecipients.error`, contextData);
1882
+ return { success: true, message: "No recipients found after substitution. Nothing to send." };
1883
+ }
1884
+
1885
+ logger.info(`[handleSendEmailAction] Preparing to send emails to ${resolvedRecipients.length} recipient(s).`);
1886
+
1887
+ const allPromises = [];
1888
+ const sentTo = [];
1889
+ const failedFor = [];
1890
+
1891
+ // 4. Itérer sur chaque destinataire pour envoyer un e-mail personnalisé
1892
+ for (const recipient of resolvedRecipients) {
1893
+ // Le destinataire peut être une simple chaîne (email) ou un objet { email: '...', nom: '...' }
1894
+ const recipientEmail = typeof recipient === 'object' && recipient !== null ? recipient.email : recipient;
1895
+
1896
+ if (!recipientEmail || typeof recipientEmail !== 'string') {
1897
+ logger.warn(`[handleSendEmailAction] Skipping an invalid recipient entry:`, recipient);
1898
+ failedFor.push(recipient); // Garder une trace de l'entrée invalide
1899
+ continue;
1900
+ }
1901
+
1902
+
1903
+ // 5. Créer un contexte personnalisé pour ce destinataire spécifique
1904
+ // Cela permet d'utiliser des placeholders comme {recipient.name}
1905
+ const personalizedContext = { ...contextData, recipient };
1906
+
1907
+ // 6. Substituer les variables dans le sujet et le contenu pour ce destinataire
1908
+ const personalizedSubject = await substituteVariables(emailSubject, personalizedContext, user);
1909
+ const personalizedBody = await substituteVariables(emailContent, personalizedContext, user);
1910
+
1911
+ const emailData = { title: personalizedSubject, content: personalizedBody };
1912
+
1913
+ // 7. Envoyer l'e-mail et suivre son résultat
1914
+ const sendPromise = sendEmail([recipientEmail], emailData, smtpConfig, user.lang)
1915
+ .then(() => {
1916
+ sentTo.push(recipient);
1917
+ })
1918
+ .catch(err => {
1919
+ logger.error(`[handleSendEmailAction] Failed to send email to ${recipientEmail}: ${err.message}`);
1920
+ failedFor.push({ recipient: recipientEmail, error: err.message });
1921
+ });
1922
+
1923
+ allPromises.push(sendPromise);
1924
+ }
1925
+
1926
+ // Attendre que toutes les tentatives d'envoi soient terminées
1927
+ await Promise.all(allPromises);
1928
+
1929
+ const summaryMessage = `Email process completed. Sent: ${sentTo.length}. Failed: ${failedFor.length}.`;
1930
+ logger.info(`[handleSendEmailAction] ${summaryMessage}`);
1931
+
1932
+ // L'action elle-même a réussi, même si certains e-mails ont échoué.
1933
+ // Le message de retour et les données fournissent les détails.
1934
+ return {
1935
+ success: true,
1936
+ message: summaryMessage,
1937
+ data: {
1938
+ sent: sentTo,
1939
+ failed: failedFor
1940
+ },
1941
+ updatedContext: {
1942
+ emailResult: {
1943
+ sent: sentTo,
1944
+ failed: failedFor
1945
+ }
1946
+ }
1947
+ };
1948
+
1949
+ } catch (error) {
1950
+ const msg = `[handleSendEmailAction] Unexpected error during email processing: ${error.message}`;
1951
+ logger.error(msg, error.stack);
1952
+ return { success: false, message: msg };
1953
+ }
1816
1954
  }