data-primals-engine 1.3.3 → 1.3.4

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