@flowuent-org/diagramming-core 1.0.6 → 1.0.7
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.
- package/TRANSLATION_FIX_SUMMARY.md +118 -0
- package/package.json +1 -1
- package/packages/diagrams/I18N_SETUP.md +126 -0
- package/packages/diagrams/README.md +443 -3
- package/packages/diagrams/locales/en/translation.json +713 -0
- package/packages/diagrams/package.json +27 -25
- package/packages/diagrams/project.json +42 -37
- package/packages/diagrams/rollup.config.js +26 -26
- package/packages/diagrams/src/index.ts +116 -113
- package/packages/diagrams/src/lib/components/automation/AutomationEndNode.tsx +8 -8
- package/packages/diagrams/src/lib/components/automation/AutomationFormattingNode.tsx +8 -8
- package/packages/diagrams/src/lib/components/automation/AutomationStartNode.tsx +9 -9
- package/packages/diagrams/src/lib/i18n.ts +42 -0
- package/packages/diagrams/src/lib/utils/AutomationExecutionEngine.ts +1168 -1162
- package/packages/diagrams/tsconfig.lib.json +25 -25
- package/tsconfig.base.json +30 -30
|
@@ -1,1162 +1,1168 @@
|
|
|
1
|
-
import { Node, Edge } from '@xyflow/react';
|
|
2
|
-
import {
|
|
3
|
-
AutomationNodeForm,
|
|
4
|
-
AutomationStartNodeForm,
|
|
5
|
-
AutomationApiNodeForm,
|
|
6
|
-
AutomationFormattingNodeForm,
|
|
7
|
-
AutomationSheetsNodeForm,
|
|
8
|
-
AutomationEndNodeForm,
|
|
9
|
-
} from '../types/automation-node-data-types';
|
|
10
|
-
import {
|
|
11
|
-
GoogleSheetsService,
|
|
12
|
-
SheetsExportOptions,
|
|
13
|
-
} from '../services/GoogleSheetsService';
|
|
14
|
-
import { SlackService } from '../services/SlackService';
|
|
15
|
-
import { TwilioWhatsAppService } from '../services/TwilioWhatsAppService';
|
|
16
|
-
|
|
17
|
-
export interface AutomationContext {
|
|
18
|
-
runId: string;
|
|
19
|
-
startTime: Date;
|
|
20
|
-
variables: Record<string, string | number | boolean | object | null>;
|
|
21
|
-
logs: AutomationLog[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface AutomationLog {
|
|
25
|
-
timestamp: Date;
|
|
26
|
-
nodeId: string;
|
|
27
|
-
nodeType: string;
|
|
28
|
-
level: 'info' | 'warning' | 'error' | 'success';
|
|
29
|
-
message: string;
|
|
30
|
-
data?: string | number | boolean | object | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface AutomationResult {
|
|
34
|
-
success: boolean;
|
|
35
|
-
context: AutomationContext;
|
|
36
|
-
finalOutput?: string | number | boolean | object | null;
|
|
37
|
-
error?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class AutomationExecutionEngine {
|
|
41
|
-
private nodes: Node[];
|
|
42
|
-
private edges: Edge[];
|
|
43
|
-
private context: AutomationContext;
|
|
44
|
-
private onNodeUpdate?: (nodeId: string, updatedData: any) => void;
|
|
45
|
-
private onLog?: (log: AutomationLog) => void;
|
|
46
|
-
|
|
47
|
-
constructor(
|
|
48
|
-
nodes: Node[],
|
|
49
|
-
edges: Edge[],
|
|
50
|
-
onNodeUpdate?: (nodeId: string, updatedData: any) => void,
|
|
51
|
-
onLog?: (log: AutomationLog) => void,
|
|
52
|
-
) {
|
|
53
|
-
this.nodes = nodes;
|
|
54
|
-
this.edges = edges;
|
|
55
|
-
this.onNodeUpdate = onNodeUpdate;
|
|
56
|
-
this.onLog = onLog;
|
|
57
|
-
this.context = {
|
|
58
|
-
runId: this.generateRunId(),
|
|
59
|
-
startTime: new Date(),
|
|
60
|
-
variables: {},
|
|
61
|
-
logs: [],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private generateRunId(): string {
|
|
66
|
-
return `run_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private storeExecutionResult(
|
|
70
|
-
nodeId: string,
|
|
71
|
-
result: string | number | boolean | object | null,
|
|
72
|
-
success: boolean = true,
|
|
73
|
-
error?: string,
|
|
74
|
-
executionTime?: number,
|
|
75
|
-
): void {
|
|
76
|
-
const node = this.nodes.find((n) => n.id === nodeId);
|
|
77
|
-
if (!node) return;
|
|
78
|
-
|
|
79
|
-
const executionResult = {
|
|
80
|
-
success,
|
|
81
|
-
data: result,
|
|
82
|
-
timestamp: new Date().toISOString(),
|
|
83
|
-
error,
|
|
84
|
-
executionTime,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// Update the node's formData with execution result
|
|
88
|
-
if (node.data.formData) {
|
|
89
|
-
(node.data.formData as any).executionResult = executionResult;
|
|
90
|
-
} else {
|
|
91
|
-
(node.data as any).executionResult = executionResult;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Notify the parent component about the node update
|
|
95
|
-
if (this.onNodeUpdate) {
|
|
96
|
-
this.onNodeUpdate(nodeId, node.data);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private log(
|
|
101
|
-
level: AutomationLog['level'],
|
|
102
|
-
nodeId: string,
|
|
103
|
-
nodeType: string,
|
|
104
|
-
message: string,
|
|
105
|
-
data?: string | number | boolean | object | null,
|
|
106
|
-
) {
|
|
107
|
-
const entry: AutomationLog = {
|
|
108
|
-
timestamp: new Date(),
|
|
109
|
-
nodeId,
|
|
110
|
-
nodeType,
|
|
111
|
-
level,
|
|
112
|
-
message,
|
|
113
|
-
data,
|
|
114
|
-
};
|
|
115
|
-
this.context.logs.push(entry);
|
|
116
|
-
if (this.onLog) {
|
|
117
|
-
try {
|
|
118
|
-
this.onLog(entry);
|
|
119
|
-
} catch {
|
|
120
|
-
// ignore UI callback errors
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private getNextNodes(currentNodeId: string): Node[] {
|
|
126
|
-
const outgoingEdges = this.edges.filter(
|
|
127
|
-
(edge) => edge.source === currentNodeId,
|
|
128
|
-
);
|
|
129
|
-
const nextNodeIds = outgoingEdges.map((edge) => edge.target);
|
|
130
|
-
return this.nodes.filter((node) => nextNodeIds.includes(node.id));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private async executeStartNode(
|
|
134
|
-
node: Node,
|
|
135
|
-
): Promise<{ runId: string; startTime: string }> {
|
|
136
|
-
const data = node.data;
|
|
137
|
-
this.log(
|
|
138
|
-
'info',
|
|
139
|
-
node.id,
|
|
140
|
-
'AutomationStartNode',
|
|
141
|
-
'Starting automation workflow',
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// Set initial context variables
|
|
145
|
-
this.context.variables.runId = this.context.runId;
|
|
146
|
-
this.context.variables.startTime = this.context.startTime.toISOString();
|
|
147
|
-
|
|
148
|
-
const result = {
|
|
149
|
-
runId: this.context.runId,
|
|
150
|
-
startTime: this.context.startTime.toISOString(),
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Store the execution result in node data
|
|
154
|
-
this.storeExecutionResult(node.id, result, true);
|
|
155
|
-
|
|
156
|
-
return result;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private async executeApiNode(node: Node): Promise<object> {
|
|
160
|
-
const data = node.data;
|
|
161
|
-
const formData = (data.formData || data) as AutomationApiNodeForm;
|
|
162
|
-
|
|
163
|
-
this.log(
|
|
164
|
-
'info',
|
|
165
|
-
node.id,
|
|
166
|
-
'AutomationApiNode',
|
|
167
|
-
`Making ${formData.method} request to ${formData.url}`,
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
const headers: Record<string, string> = {};
|
|
172
|
-
if (formData.headers && Array.isArray(formData.headers)) {
|
|
173
|
-
formData.headers.forEach((header) => {
|
|
174
|
-
if (header.enabled) {
|
|
175
|
-
headers[header.key] = header.value;
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const queryParams = new URLSearchParams();
|
|
181
|
-
if (formData.queryParams && Array.isArray(formData.queryParams)) {
|
|
182
|
-
formData.queryParams.forEach((param) => {
|
|
183
|
-
if (param.enabled) {
|
|
184
|
-
queryParams.append(param.key, param.value);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const url = queryParams.toString()
|
|
190
|
-
? `${formData.url}?${queryParams.toString()}`
|
|
191
|
-
: formData.url;
|
|
192
|
-
|
|
193
|
-
const requestOptions: RequestInit = {
|
|
194
|
-
method: formData.method,
|
|
195
|
-
headers,
|
|
196
|
-
signal: AbortSignal.timeout(formData.timeout || 30000),
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
formData.body &&
|
|
201
|
-
(formData.method === 'POST' || formData.method === 'PUT')
|
|
202
|
-
) {
|
|
203
|
-
requestOptions.body = formData.body;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const response = await fetch(url, requestOptions);
|
|
207
|
-
|
|
208
|
-
if (!response.ok) {
|
|
209
|
-
throw new Error(
|
|
210
|
-
`HTTP ${response.status}: ${response.statusText} - ${url}`,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const responseData = await response.json();
|
|
215
|
-
this.log(
|
|
216
|
-
'success',
|
|
217
|
-
node.id,
|
|
218
|
-
'AutomationApiNode',
|
|
219
|
-
'API call completed successfully',
|
|
220
|
-
responseData,
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
// Store the execution result in node data
|
|
224
|
-
this.storeExecutionResult(node.id, responseData, true);
|
|
225
|
-
|
|
226
|
-
return responseData;
|
|
227
|
-
} catch (error) {
|
|
228
|
-
let errorMessage = `API call failed: ${error}`;
|
|
229
|
-
|
|
230
|
-
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
231
|
-
errorMessage = `Network error: Unable to connect to ${formData.url}. This might be due to CORS restrictions or network connectivity issues.`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
this.log(
|
|
235
|
-
'error',
|
|
236
|
-
node.id,
|
|
237
|
-
'AutomationApiNode',
|
|
238
|
-
errorMessage,
|
|
239
|
-
error instanceof Error ? error.message : String(error),
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
// Store the error result in node data
|
|
243
|
-
this.storeExecutionResult(node.id, null, false, errorMessage);
|
|
244
|
-
|
|
245
|
-
throw new Error(errorMessage);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private async executeFormattingNode(
|
|
250
|
-
node: Node,
|
|
251
|
-
inputData: string | number | boolean | object | null,
|
|
252
|
-
): Promise<string | number | boolean | object | null> {
|
|
253
|
-
const data = node.data;
|
|
254
|
-
const formData = (data.formData || data) as AutomationFormattingNodeForm;
|
|
255
|
-
|
|
256
|
-
this.log(
|
|
257
|
-
'info',
|
|
258
|
-
node.id,
|
|
259
|
-
'AutomationFormattingNode',
|
|
260
|
-
'Starting data formatting',
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
if (formData.formattingType === 'ai-powered' && formData.aiFormatting) {
|
|
265
|
-
// AI-powered formatting
|
|
266
|
-
this.log('info', node.id, 'AutomationFormattingNode', 'AI thinking...');
|
|
267
|
-
const aiResponse = await this.callAiApi(
|
|
268
|
-
formData.aiFormatting,
|
|
269
|
-
inputData,
|
|
270
|
-
);
|
|
271
|
-
this.log(
|
|
272
|
-
'success',
|
|
273
|
-
node.id,
|
|
274
|
-
'AutomationFormattingNode',
|
|
275
|
-
'AI formatting completed',
|
|
276
|
-
aiResponse,
|
|
277
|
-
);
|
|
278
|
-
|
|
279
|
-
// Store the execution result in node data
|
|
280
|
-
this.storeExecutionResult(node.id, aiResponse, true);
|
|
281
|
-
|
|
282
|
-
return aiResponse;
|
|
283
|
-
} else {
|
|
284
|
-
// Basic formatting
|
|
285
|
-
const formattedData = this.basicFormatting(inputData, formData);
|
|
286
|
-
this.log(
|
|
287
|
-
'success',
|
|
288
|
-
node.id,
|
|
289
|
-
'AutomationFormattingNode',
|
|
290
|
-
'Basic formatting completed',
|
|
291
|
-
formattedData,
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
// Store the execution result in node data
|
|
295
|
-
this.storeExecutionResult(node.id, formattedData, true);
|
|
296
|
-
|
|
297
|
-
return formattedData;
|
|
298
|
-
}
|
|
299
|
-
} catch (error) {
|
|
300
|
-
this.log(
|
|
301
|
-
'error',
|
|
302
|
-
node.id,
|
|
303
|
-
'AutomationFormattingNode',
|
|
304
|
-
`Formatting failed: ${error}`,
|
|
305
|
-
error instanceof Error ? error.message : String(error),
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
// Store the error result in node data
|
|
309
|
-
this.storeExecutionResult(
|
|
310
|
-
node.id,
|
|
311
|
-
null,
|
|
312
|
-
false,
|
|
313
|
-
error instanceof Error ? error.message : String(error),
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
private async callAiApi(
|
|
321
|
-
aiConfig: NonNullable<AutomationFormattingNodeForm['aiFormatting']>,
|
|
322
|
-
inputData: string | number | boolean | object | null,
|
|
323
|
-
): Promise<string | number | boolean | object | null> {
|
|
324
|
-
// Validate API key is not a placeholder
|
|
325
|
-
if (
|
|
326
|
-
!aiConfig.apiKey ||
|
|
327
|
-
aiConfig.apiKey.includes('your-openai-api-key-here') ||
|
|
328
|
-
aiConfig.apiKey.length < 10
|
|
329
|
-
) {
|
|
330
|
-
throw new Error('Invalid API key. Please provide a valid API key.');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Additional validation for Gemini API key format
|
|
334
|
-
if (aiConfig.apiUrl.includes('generativelanguage.googleapis.com')) {
|
|
335
|
-
if (!aiConfig.apiKey.startsWith('AIza')) {
|
|
336
|
-
throw new Error(
|
|
337
|
-
'Invalid Gemini API key format. Gemini API keys should start with "AIza".',
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const prompt = `${aiConfig.instruction}\n\nInput data: ${JSON.stringify(inputData)}`;
|
|
343
|
-
|
|
344
|
-
// Determine if this is Gemini API or OpenAI API based on URL
|
|
345
|
-
const isGeminiApi = aiConfig.apiUrl.includes(
|
|
346
|
-
'generativelanguage.googleapis.com',
|
|
347
|
-
);
|
|
348
|
-
|
|
349
|
-
let requestBody: any;
|
|
350
|
-
let headers: Record<string, string>;
|
|
351
|
-
|
|
352
|
-
if (isGeminiApi) {
|
|
353
|
-
// Gemini API format
|
|
354
|
-
headers = {
|
|
355
|
-
'Content-Type': 'application/json',
|
|
356
|
-
'X-goog-api-key': aiConfig.apiKey,
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
requestBody = {
|
|
360
|
-
contents: [
|
|
361
|
-
{
|
|
362
|
-
parts: [
|
|
363
|
-
{
|
|
364
|
-
text: `${aiConfig.systemPrompt || 'You are a data formatting assistant.'}\n\n${prompt}`,
|
|
365
|
-
},
|
|
366
|
-
],
|
|
367
|
-
},
|
|
368
|
-
],
|
|
369
|
-
generationConfig: {
|
|
370
|
-
temperature: aiConfig.temperature || 0.1,
|
|
371
|
-
maxOutputTokens: aiConfig.maxTokens || 1000,
|
|
372
|
-
responseMimeType: 'application/json',
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
} else {
|
|
376
|
-
// OpenAI API format
|
|
377
|
-
headers = {
|
|
378
|
-
'Content-Type': 'application/json',
|
|
379
|
-
Authorization: `Bearer ${aiConfig.apiKey}`,
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
requestBody = {
|
|
383
|
-
model: aiConfig.model,
|
|
384
|
-
messages: [
|
|
385
|
-
{
|
|
386
|
-
role: 'system',
|
|
387
|
-
content:
|
|
388
|
-
aiConfig.systemPrompt || 'You are a data formatting assistant.',
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
role: 'user',
|
|
392
|
-
content: prompt,
|
|
393
|
-
},
|
|
394
|
-
],
|
|
395
|
-
temperature: aiConfig.temperature || 0.1,
|
|
396
|
-
max_tokens: aiConfig.maxTokens || 1000,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const response = await fetch(aiConfig.apiUrl, {
|
|
401
|
-
method: 'POST',
|
|
402
|
-
headers,
|
|
403
|
-
body: JSON.stringify(requestBody),
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
if (!response.ok) {
|
|
407
|
-
const errorText = await response.text();
|
|
408
|
-
throw new Error(
|
|
409
|
-
`AI API call failed: ${response.status} ${response.statusText}. ${errorText}`,
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const result = await response.json();
|
|
414
|
-
|
|
415
|
-
let content: string;
|
|
416
|
-
|
|
417
|
-
if (isGeminiApi) {
|
|
418
|
-
// Gemini API response format
|
|
419
|
-
if (
|
|
420
|
-
!result.candidates ||
|
|
421
|
-
!result.candidates[0] ||
|
|
422
|
-
!result.candidates[0].content ||
|
|
423
|
-
!result.candidates[0].content.parts
|
|
424
|
-
) {
|
|
425
|
-
throw new Error('Invalid Gemini API response format');
|
|
426
|
-
}
|
|
427
|
-
content = result.candidates[0].content.parts[0].text;
|
|
428
|
-
} else {
|
|
429
|
-
// OpenAI API response format
|
|
430
|
-
if (!result.choices || !result.choices[0] || !result.choices[0].message) {
|
|
431
|
-
throw new Error('Invalid AI API response format');
|
|
432
|
-
}
|
|
433
|
-
content = result.choices[0].message.content;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Try to parse as JSON, fallback to string if parsing fails
|
|
437
|
-
try {
|
|
438
|
-
return JSON.parse(content);
|
|
439
|
-
} catch {
|
|
440
|
-
return content;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
private basicFormatting(
|
|
445
|
-
inputData: string | number | boolean | object | null,
|
|
446
|
-
config: AutomationFormattingNodeForm,
|
|
447
|
-
): string | number | boolean | object | null {
|
|
448
|
-
// Basic data transformation logic
|
|
449
|
-
if (typeof inputData === 'string') {
|
|
450
|
-
return inputData.trim();
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (Array.isArray(inputData)) {
|
|
454
|
-
return inputData.map((item) => this.basicFormatting(item, config));
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (typeof inputData === 'object' && inputData !== null) {
|
|
458
|
-
const formatted: Record<string, unknown> = {};
|
|
459
|
-
Object.keys(inputData).forEach((key) => {
|
|
460
|
-
formatted[key] = this.basicFormatting(
|
|
461
|
-
(inputData as Record<string, unknown>)[key] as
|
|
462
|
-
| string
|
|
463
|
-
| number
|
|
464
|
-
| boolean
|
|
465
|
-
| object
|
|
466
|
-
| null,
|
|
467
|
-
config,
|
|
468
|
-
);
|
|
469
|
-
});
|
|
470
|
-
return formatted;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return inputData;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
private async executeSheetsNode(
|
|
477
|
-
node: Node,
|
|
478
|
-
inputData: string | number | boolean | object | null,
|
|
479
|
-
): Promise<string | number | boolean | object | null> {
|
|
480
|
-
const data = node.data;
|
|
481
|
-
const formData = (data.formData || data) as AutomationSheetsNodeForm;
|
|
482
|
-
|
|
483
|
-
this.log(
|
|
484
|
-
'info',
|
|
485
|
-
node.id,
|
|
486
|
-
'AutomationSheetsNode',
|
|
487
|
-
'Starting Google Sheets export',
|
|
488
|
-
);
|
|
489
|
-
|
|
490
|
-
try {
|
|
491
|
-
// Initialize/derive per-output-method statuses container
|
|
492
|
-
const outputStatuses: any = (data as any).outputStatuses || {};
|
|
493
|
-
// Only set Google Sheets status when exportFormat includes sheets
|
|
494
|
-
if (
|
|
495
|
-
formData.exportOptions?.exportFormat === 'sheets' ||
|
|
496
|
-
formData.exportOptions?.exportFormat === 'both'
|
|
497
|
-
) {
|
|
498
|
-
outputStatuses.googleSheets = 'running';
|
|
499
|
-
}
|
|
500
|
-
// Gmail runs only if enabled
|
|
501
|
-
if (formData.exportOptions?.emailSendEnabled) {
|
|
502
|
-
outputStatuses.gmail = 'running';
|
|
503
|
-
}
|
|
504
|
-
// Slack runs only if enabled
|
|
505
|
-
if (formData.exportOptions?.slack?.enabled) {
|
|
506
|
-
outputStatuses.slack = 'running';
|
|
507
|
-
}
|
|
508
|
-
// WhatsApp runs only if enabled
|
|
509
|
-
if (formData.exportOptions?.whatsapp?.enabled) {
|
|
510
|
-
outputStatuses.whatsapp = 'running';
|
|
511
|
-
}
|
|
512
|
-
(data as any).outputStatuses = outputStatuses;
|
|
513
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
514
|
-
|
|
515
|
-
const sheetsConfig = formData.sheetsConfig;
|
|
516
|
-
const dataMapping = formData.dataMapping;
|
|
517
|
-
const exportOptions = formData.exportOptions;
|
|
518
|
-
const wantsSheets =
|
|
519
|
-
exportOptions?.exportFormat === 'sheets' ||
|
|
520
|
-
exportOptions?.exportFormat === 'both';
|
|
521
|
-
const wantsExcel =
|
|
522
|
-
exportOptions?.exportFormat === 'excel' ||
|
|
523
|
-
exportOptions?.exportFormat === 'both';
|
|
524
|
-
const wantsGmail = Boolean(exportOptions?.emailSendEnabled);
|
|
525
|
-
|
|
526
|
-
const sheetsService = new GoogleSheetsService();
|
|
527
|
-
// Only perform Google auth/validation when Sheets or Gmail is in use
|
|
528
|
-
if (wantsSheets || wantsGmail) {
|
|
529
|
-
this.log(
|
|
530
|
-
'info',
|
|
531
|
-
node.id,
|
|
532
|
-
'AutomationSheetsNode',
|
|
533
|
-
'Authenticating with Google (Sheets/Gmail)...',
|
|
534
|
-
);
|
|
535
|
-
// If Gmail send is enabled and we're using OAuth client, request both scopes in one consent
|
|
536
|
-
try {
|
|
537
|
-
const creds: any = sheetsConfig?.credentials || {};
|
|
538
|
-
if (creds.type === 'oauth' && (creds.clientId || creds.oauthToken)) {
|
|
539
|
-
const neededScopes = [
|
|
540
|
-
'https://www.googleapis.com/auth/spreadsheets',
|
|
541
|
-
];
|
|
542
|
-
if (exportOptions?.emailSendEnabled) {
|
|
543
|
-
neededScopes.push('https://www.googleapis.com/auth/gmail.send');
|
|
544
|
-
}
|
|
545
|
-
const existing = Array.isArray(creds.scopes) ? creds.scopes : [];
|
|
546
|
-
const union = Array.from(new Set([...existing, ...neededScopes]));
|
|
547
|
-
creds.scopes = union;
|
|
548
|
-
sheetsConfig.credentials = creds;
|
|
549
|
-
}
|
|
550
|
-
} catch {
|
|
551
|
-
// Non-fatal; initialization will still proceed with default scopes
|
|
552
|
-
}
|
|
553
|
-
const validation = sheetsService.validateConfig(sheetsConfig);
|
|
554
|
-
if (!validation.valid) {
|
|
555
|
-
throw new Error(
|
|
556
|
-
`Configuration validation failed: ${validation.errors.join(', ')}`,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Prepare data for export (only when exporting to Sheets/Excel)
|
|
562
|
-
let exportData: any[] = [];
|
|
563
|
-
if (wantsSheets || wantsExcel) {
|
|
564
|
-
if (Array.isArray(inputData)) {
|
|
565
|
-
exportData = inputData;
|
|
566
|
-
} else if (inputData && typeof inputData === 'object') {
|
|
567
|
-
exportData = [inputData];
|
|
568
|
-
} else {
|
|
569
|
-
throw new Error('Invalid input data format for export');
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Initialize Google Sheets service only when using Sheets or Gmail
|
|
574
|
-
if (wantsSheets || wantsGmail) {
|
|
575
|
-
await sheetsService.initialize(sheetsConfig);
|
|
576
|
-
this.log(
|
|
577
|
-
'success',
|
|
578
|
-
node.id,
|
|
579
|
-
'AutomationSheetsNode',
|
|
580
|
-
'Authenticated with Google APIs (Sheets/Gmail)',
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Prepare export options
|
|
585
|
-
const sheetsExportOptions: SheetsExportOptions = {
|
|
586
|
-
emailRecipients: exportOptions.emailRecipients,
|
|
587
|
-
fileName: exportOptions.fileName,
|
|
588
|
-
exportFormat:
|
|
589
|
-
exportOptions.exportFormat === 'both'
|
|
590
|
-
? 'sheets'
|
|
591
|
-
: exportOptions.exportFormat,
|
|
592
|
-
includeHeaders: exportOptions.includeHeaders,
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
let response: any = null;
|
|
596
|
-
|
|
597
|
-
// Export to Google Sheets
|
|
598
|
-
if (
|
|
599
|
-
exportOptions?.exportFormat === 'sheets' ||
|
|
600
|
-
exportOptions?.exportFormat === 'both'
|
|
601
|
-
) {
|
|
602
|
-
this.log(
|
|
603
|
-
'info',
|
|
604
|
-
node.id,
|
|
605
|
-
'AutomationSheetsNode',
|
|
606
|
-
'Writing data to Google Sheets...',
|
|
607
|
-
);
|
|
608
|
-
response = await sheetsService.exportToSheets(
|
|
609
|
-
exportData,
|
|
610
|
-
sheetsConfig,
|
|
611
|
-
dataMapping,
|
|
612
|
-
sheetsExportOptions,
|
|
613
|
-
);
|
|
614
|
-
|
|
615
|
-
this.log(
|
|
616
|
-
'success',
|
|
617
|
-
node.id,
|
|
618
|
-
'AutomationSheetsNode',
|
|
619
|
-
`Successfully exported ${response.rowsAdded} rows to Google Sheets`,
|
|
620
|
-
response,
|
|
621
|
-
);
|
|
622
|
-
// Mark GS connected on success
|
|
623
|
-
outputStatuses.googleSheets = 'connected';
|
|
624
|
-
(data as any).outputStatuses = outputStatuses;
|
|
625
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Export to Excel
|
|
629
|
-
if (
|
|
630
|
-
exportOptions.exportFormat === 'excel' ||
|
|
631
|
-
exportOptions.exportFormat === 'both'
|
|
632
|
-
) {
|
|
633
|
-
this.log(
|
|
634
|
-
'info',
|
|
635
|
-
node.id,
|
|
636
|
-
'AutomationSheetsNode',
|
|
637
|
-
'Exporting data to Excel...',
|
|
638
|
-
);
|
|
639
|
-
const excelResponse = await sheetsService.exportToExcel(
|
|
640
|
-
exportData,
|
|
641
|
-
dataMapping,
|
|
642
|
-
sheetsExportOptions,
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
this.log(
|
|
646
|
-
'success',
|
|
647
|
-
node.id,
|
|
648
|
-
'AutomationSheetsNode',
|
|
649
|
-
`Successfully exported ${excelResponse.rowsAdded} rows to Excel`,
|
|
650
|
-
excelResponse,
|
|
651
|
-
);
|
|
652
|
-
|
|
653
|
-
// If we're doing both, merge the responses
|
|
654
|
-
if (exportOptions.exportFormat === 'both') {
|
|
655
|
-
response = {
|
|
656
|
-
...response,
|
|
657
|
-
excelExport: excelResponse,
|
|
658
|
-
combinedSuccess: response.success && excelResponse.success,
|
|
659
|
-
};
|
|
660
|
-
} else {
|
|
661
|
-
response = excelResponse;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Optional email send (via mailto fallback or Gmail API if implemented)
|
|
666
|
-
if (exportOptions.emailSendEnabled) {
|
|
667
|
-
const emailRecipients = exportOptions.emailRecipients || [];
|
|
668
|
-
if (emailRecipients.length > 0) {
|
|
669
|
-
this.log(
|
|
670
|
-
'info',
|
|
671
|
-
node.id,
|
|
672
|
-
'AutomationSheetsNode',
|
|
673
|
-
`Sending email to ${emailRecipients.length} recipient(s)...`,
|
|
674
|
-
);
|
|
675
|
-
const emailSent = await sheetsService.sendEmail({
|
|
676
|
-
from: exportOptions.emailSender,
|
|
677
|
-
to: emailRecipients,
|
|
678
|
-
subject: exportOptions.emailSubject || 'Automation Output',
|
|
679
|
-
message: exportOptions.emailMessage,
|
|
680
|
-
spreadsheetUrl:
|
|
681
|
-
exportOptions.includeSpreadsheetLink &&
|
|
682
|
-
(response as any)?.spreadsheetUrl
|
|
683
|
-
? (response as any).spreadsheetUrl
|
|
684
|
-
: undefined,
|
|
685
|
-
// Note: attachExcel via Gmail requires Gmail scope and MIME building;
|
|
686
|
-
// mailto cannot attach files. This is a placeholder hook.
|
|
687
|
-
attachExcelBlob: undefined,
|
|
688
|
-
attachExcelFileName: sheetsExportOptions.fileName,
|
|
689
|
-
// Critical: disable UI fallback to ensure fully automated send
|
|
690
|
-
disableUiFallback: true,
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
if (emailSent) {
|
|
694
|
-
this.log(
|
|
695
|
-
'success',
|
|
696
|
-
node.id,
|
|
697
|
-
'AutomationSheetsNode',
|
|
698
|
-
`Email sent to ${emailRecipients.length} recipient(s)`,
|
|
699
|
-
);
|
|
700
|
-
outputStatuses.gmail = 'connected';
|
|
701
|
-
} else {
|
|
702
|
-
this.log(
|
|
703
|
-
'error',
|
|
704
|
-
node.id,
|
|
705
|
-
'AutomationSheetsNode',
|
|
706
|
-
'Email send failed (API)',
|
|
707
|
-
);
|
|
708
|
-
outputStatuses.gmail = 'failed';
|
|
709
|
-
}
|
|
710
|
-
(data as any).outputStatuses = outputStatuses;
|
|
711
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Optional Slack send via webhook
|
|
716
|
-
if (exportOptions.slack?.enabled) {
|
|
717
|
-
const slackService = new SlackService();
|
|
718
|
-
const validation = slackService.validateConfig(exportOptions.slack);
|
|
719
|
-
if (!validation.valid) {
|
|
720
|
-
throw new Error(
|
|
721
|
-
`Slack configuration invalid: ${validation.errors.join(', ')}`,
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Build message text with simple token replacements
|
|
726
|
-
const template =
|
|
727
|
-
exportOptions.slack.messageTemplate ||
|
|
728
|
-
'Automation update for {{fileName}}';
|
|
729
|
-
const contextVars: Record<string, any> = {
|
|
730
|
-
fileName: exportOptions.fileName,
|
|
731
|
-
rowsAdded: (response as any)?.rowsAdded,
|
|
732
|
-
sheetName:
|
|
733
|
-
(response as any)?.sheetName || formData.sheetsConfig?.sheetName,
|
|
734
|
-
};
|
|
735
|
-
const messageText = template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
|
|
736
|
-
key in contextVars ? String(contextVars[key]) : '',
|
|
737
|
-
);
|
|
738
|
-
|
|
739
|
-
// Prefer spreadsheetUrl from Sheets response if available and flag set
|
|
740
|
-
const spreadsheetUrl = exportOptions.slack.includeSpreadsheetLink
|
|
741
|
-
? (response as any)?.spreadsheetUrl || undefined
|
|
742
|
-
: undefined;
|
|
743
|
-
|
|
744
|
-
const payload = slackService.buildPayload(
|
|
745
|
-
exportOptions.slack,
|
|
746
|
-
messageText,
|
|
747
|
-
inputData,
|
|
748
|
-
spreadsheetUrl,
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
try {
|
|
752
|
-
this.log(
|
|
753
|
-
'info',
|
|
754
|
-
node.id,
|
|
755
|
-
'AutomationSheetsNode',
|
|
756
|
-
`Sending Slack message to #${exportOptions.slack.channel}...`,
|
|
757
|
-
);
|
|
758
|
-
await slackService.sendMessage({
|
|
759
|
-
webhookUrl: exportOptions.slack.webhookUrl,
|
|
760
|
-
payload,
|
|
761
|
-
});
|
|
762
|
-
this.log(
|
|
763
|
-
'success',
|
|
764
|
-
node.id,
|
|
765
|
-
'AutomationSheetsNode',
|
|
766
|
-
`Slack message sent to #${exportOptions.slack.channel}`,
|
|
767
|
-
);
|
|
768
|
-
outputStatuses.slack = 'connected';
|
|
769
|
-
(data as any).outputStatuses = outputStatuses;
|
|
770
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
771
|
-
} catch (slackErr) {
|
|
772
|
-
const msg =
|
|
773
|
-
slackErr instanceof Error ? slackErr.message : String(slackErr);
|
|
774
|
-
this.log(
|
|
775
|
-
'error',
|
|
776
|
-
node.id,
|
|
777
|
-
'AutomationSheetsNode',
|
|
778
|
-
`Slack send failed: ${msg}`,
|
|
779
|
-
);
|
|
780
|
-
// Do not fail the whole workflow due to Slack CORS; mark Slack as failed but continue
|
|
781
|
-
outputStatuses.slack = 'failed';
|
|
782
|
-
(data as any).outputStatuses = outputStatuses;
|
|
783
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Optional WhatsApp send
|
|
788
|
-
if (exportOptions.whatsapp?.enabled) {
|
|
789
|
-
try {
|
|
790
|
-
this.log(
|
|
791
|
-
'info',
|
|
792
|
-
node.id,
|
|
793
|
-
'AutomationSheetsNode',
|
|
794
|
-
`Sending WhatsApp message to ${exportOptions.whatsapp.phoneNumber}...`,
|
|
795
|
-
);
|
|
796
|
-
|
|
797
|
-
// Build WhatsApp message with template replacements
|
|
798
|
-
const template =
|
|
799
|
-
exportOptions.whatsapp.messageTemplate ||
|
|
800
|
-
'Automation Result: {{fileName}} completed successfully. Rows processed: {{rowsAdded}}.';
|
|
801
|
-
const contextVars: Record<string, any> = {
|
|
802
|
-
fileName: exportOptions.fileName,
|
|
803
|
-
rowsAdded: (response as any)?.rowsAdded,
|
|
804
|
-
sheetName:
|
|
805
|
-
(response as any)?.sheetName || formData.sheetsConfig?.sheetName,
|
|
806
|
-
timestamp: new Date().toLocaleString(),
|
|
807
|
-
};
|
|
808
|
-
const messageText = template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
|
|
809
|
-
key in contextVars ? String(contextVars[key]) : '',
|
|
810
|
-
);
|
|
811
|
-
|
|
812
|
-
// Create WhatsApp message with execution data
|
|
813
|
-
const whatsappMessage = `
|
|
814
|
-
Automation Result:
|
|
815
|
-
------------------
|
|
816
|
-
${messageText}
|
|
817
|
-
|
|
818
|
-
Execution Details:
|
|
819
|
-
- Run ID: ${this.context.runId}
|
|
820
|
-
- Start Time: ${this.context.startTime.toLocaleString()}
|
|
821
|
-
- Status: Completed Successfully
|
|
822
|
-
- Data: ${JSON.stringify(inputData, null, 2)}
|
|
823
|
-
`;
|
|
824
|
-
|
|
825
|
-
// Check if Twilio is configured
|
|
826
|
-
if (exportOptions.whatsapp.twilio?.enabled) {
|
|
827
|
-
// Use Twilio WhatsApp API
|
|
828
|
-
const twilioService = new TwilioWhatsAppService({
|
|
829
|
-
accountSid: exportOptions.whatsapp.twilio.accountSid,
|
|
830
|
-
authToken: exportOptions.whatsapp.twilio.authToken,
|
|
831
|
-
fromNumber: exportOptions.whatsapp.twilio.fromNumber,
|
|
832
|
-
isSandbox: exportOptions.whatsapp.twilio.isSandbox,
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
// Validate Twilio configuration
|
|
836
|
-
const validation = twilioService.validateConfig();
|
|
837
|
-
if (!validation.valid) {
|
|
838
|
-
throw new Error(
|
|
839
|
-
`Twilio configuration invalid: ${validation.errors.join(', ')}`,
|
|
840
|
-
);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Send message via Twilio
|
|
844
|
-
|
|
845
|
-
const twilioResult = await twilioService.sendMessage({
|
|
846
|
-
to: exportOptions.whatsapp.phoneNumber,
|
|
847
|
-
message: whatsappMessage.trim(),
|
|
848
|
-
templateSid: exportOptions.whatsapp.twilio.templateSid,
|
|
849
|
-
templateVariables:
|
|
850
|
-
exportOptions.whatsapp.twilio.templateVariables,
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
if (twilioResult.success) {
|
|
854
|
-
this.log(
|
|
855
|
-
'success',
|
|
856
|
-
node.id,
|
|
857
|
-
'AutomationSheetsNode',
|
|
858
|
-
`WhatsApp message sent via Twilio (SID: ${twilioResult.messageSid})`,
|
|
859
|
-
);
|
|
860
|
-
outputStatuses.whatsapp = 'connected';
|
|
861
|
-
} else {
|
|
862
|
-
throw new Error(`Twilio send failed: ${twilioResult.error}`);
|
|
863
|
-
}
|
|
864
|
-
} else {
|
|
865
|
-
// Fallback to WhatsApp Web (existing behavior)
|
|
866
|
-
const encodedMsg = encodeURIComponent(whatsappMessage.trim());
|
|
867
|
-
const whatsappUrl = `https://wa.me/${exportOptions.whatsapp.phoneNumber}?text=${encodedMsg}`;
|
|
868
|
-
|
|
869
|
-
// Open WhatsApp in a new tab
|
|
870
|
-
window.open(whatsappUrl, '_blank');
|
|
871
|
-
|
|
872
|
-
this.log(
|
|
873
|
-
'success',
|
|
874
|
-
node.id,
|
|
875
|
-
'AutomationSheetsNode',
|
|
876
|
-
`WhatsApp message opened for ${exportOptions.whatsapp.phoneNumber}`,
|
|
877
|
-
);
|
|
878
|
-
outputStatuses.whatsapp = 'connected';
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
(data as any).outputStatuses = outputStatuses;
|
|
882
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
883
|
-
} catch (whatsappErr) {
|
|
884
|
-
const msg =
|
|
885
|
-
whatsappErr instanceof Error
|
|
886
|
-
? whatsappErr.message
|
|
887
|
-
: String(whatsappErr);
|
|
888
|
-
this.log(
|
|
889
|
-
'error',
|
|
890
|
-
node.id,
|
|
891
|
-
'AutomationSheetsNode',
|
|
892
|
-
`WhatsApp send failed: ${msg}`,
|
|
893
|
-
);
|
|
894
|
-
outputStatuses.whatsapp = 'failed';
|
|
895
|
-
(data as any).outputStatuses = outputStatuses;
|
|
896
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Store the execution result in node data
|
|
901
|
-
this.storeExecutionResult(node.id, response, true);
|
|
902
|
-
|
|
903
|
-
return response;
|
|
904
|
-
} catch (error) {
|
|
905
|
-
const errorMessage =
|
|
906
|
-
error instanceof Error ? error.message : 'Unknown error';
|
|
907
|
-
this.log(
|
|
908
|
-
'error',
|
|
909
|
-
node.id,
|
|
910
|
-
'AutomationSheetsNode',
|
|
911
|
-
`Google Sheets export failed: ${errorMessage}`,
|
|
912
|
-
);
|
|
913
|
-
|
|
914
|
-
// Mark failing outputs
|
|
915
|
-
const dataRef: any = node.data;
|
|
916
|
-
dataRef.outputStatuses = dataRef.outputStatuses || {};
|
|
917
|
-
// If config missing -> not-set should be turned failed on workflow failure
|
|
918
|
-
const sheetsCfg =
|
|
919
|
-
dataRef.formData?.sheetsConfig || dataRef.sheetsConfig || {};
|
|
920
|
-
const creds = sheetsCfg.credentials || {};
|
|
921
|
-
const gsHasRequired = Boolean(
|
|
922
|
-
(sheetsCfg.spreadsheetId || creds.clientId) &&
|
|
923
|
-
sheetsCfg.sheetName &&
|
|
924
|
-
creds.type,
|
|
925
|
-
);
|
|
926
|
-
dataRef.outputStatuses.googleSheets = gsHasRequired ? 'failed' : 'failed';
|
|
927
|
-
|
|
928
|
-
const ex = dataRef.formData?.exportOptions || dataRef.exportOptions || {};
|
|
929
|
-
const gmHasRequired = Boolean(
|
|
930
|
-
ex.emailSendEnabled &&
|
|
931
|
-
ex.emailSender &&
|
|
932
|
-
(ex.emailRecipients?.length || 0) > 0 &&
|
|
933
|
-
ex.emailSubject &&
|
|
934
|
-
ex.emailMessage,
|
|
935
|
-
);
|
|
936
|
-
if (ex.emailSendEnabled) {
|
|
937
|
-
dataRef.outputStatuses.gmail = gmHasRequired ? 'failed' : 'failed';
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
const sl = ex.slack || {};
|
|
941
|
-
const slackHasRequired = Boolean(
|
|
942
|
-
sl.enabled && sl.webhookUrl && sl.channel,
|
|
943
|
-
);
|
|
944
|
-
if (sl.enabled) {
|
|
945
|
-
dataRef.outputStatuses.slack = slackHasRequired ? 'failed' : 'failed';
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const wa = ex.whatsapp || {};
|
|
949
|
-
const whatsappHasRequired = Boolean(wa.enabled && wa.phoneNumber);
|
|
950
|
-
if (wa.enabled) {
|
|
951
|
-
dataRef.outputStatuses.whatsapp = whatsappHasRequired
|
|
952
|
-
? 'failed'
|
|
953
|
-
: 'failed';
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
if (this.onNodeUpdate) this.onNodeUpdate(node.id, dataRef);
|
|
957
|
-
|
|
958
|
-
// Store the execution result in node data
|
|
959
|
-
this.storeExecutionResult(node.id, null, false, errorMessage);
|
|
960
|
-
|
|
961
|
-
throw error;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
private getNestedValue(obj: any, path: string): any {
|
|
966
|
-
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
private formatValueForSheets(value: any, dataType: string): any {
|
|
970
|
-
if (value === null || value === undefined) {
|
|
971
|
-
return '';
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
switch (dataType) {
|
|
975
|
-
case 'string':
|
|
976
|
-
return String(value);
|
|
977
|
-
case 'number':
|
|
978
|
-
return Number(value);
|
|
979
|
-
case 'date':
|
|
980
|
-
return new Date(value).toISOString();
|
|
981
|
-
case 'boolean':
|
|
982
|
-
return Boolean(value);
|
|
983
|
-
default:
|
|
984
|
-
return String(value);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
private async executeEndNode(
|
|
989
|
-
node: Node,
|
|
990
|
-
inputData: string | number | boolean | object | null,
|
|
991
|
-
): Promise<string | number | boolean | object | null> {
|
|
992
|
-
const data = node.data;
|
|
993
|
-
const formData = (data.formData || data) as AutomationEndNodeForm;
|
|
994
|
-
|
|
995
|
-
this.log(
|
|
996
|
-
'info',
|
|
997
|
-
node.id,
|
|
998
|
-
'AutomationEndNode',
|
|
999
|
-
'Workflow completed successfully',
|
|
1000
|
-
);
|
|
1001
|
-
|
|
1002
|
-
// Handle different output types
|
|
1003
|
-
switch (formData.outputType) {
|
|
1004
|
-
case 'display':
|
|
1005
|
-
this.log(
|
|
1006
|
-
'info',
|
|
1007
|
-
node.id,
|
|
1008
|
-
'AutomationEndNode',
|
|
1009
|
-
'Output displayed in UI',
|
|
1010
|
-
inputData,
|
|
1011
|
-
);
|
|
1012
|
-
break;
|
|
1013
|
-
case 'store':
|
|
1014
|
-
this.log(
|
|
1015
|
-
'info',
|
|
1016
|
-
node.id,
|
|
1017
|
-
'AutomationEndNode',
|
|
1018
|
-
'Data stored to destination',
|
|
1019
|
-
inputData,
|
|
1020
|
-
);
|
|
1021
|
-
break;
|
|
1022
|
-
case 'send':
|
|
1023
|
-
this.log(
|
|
1024
|
-
'info',
|
|
1025
|
-
node.id,
|
|
1026
|
-
'AutomationEndNode',
|
|
1027
|
-
'Data sent to destination',
|
|
1028
|
-
inputData,
|
|
1029
|
-
);
|
|
1030
|
-
break;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// Store the execution result in node data
|
|
1034
|
-
this.storeExecutionResult(node.id, inputData, true);
|
|
1035
|
-
|
|
1036
|
-
return inputData;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
private async executeNode(
|
|
1040
|
-
node: Node,
|
|
1041
|
-
inputData?: string | number | boolean | object | null,
|
|
1042
|
-
): Promise<string | number | boolean | object | null> {
|
|
1043
|
-
const nodeType = node.type;
|
|
1044
|
-
|
|
1045
|
-
switch (nodeType) {
|
|
1046
|
-
case 'AutomationStartNode':
|
|
1047
|
-
return await this.executeStartNode(node);
|
|
1048
|
-
case 'AutomationApiNode':
|
|
1049
|
-
return await this.executeApiNode(node);
|
|
1050
|
-
case 'AutomationFormattingNode':
|
|
1051
|
-
return await this.executeFormattingNode(node, inputData || null);
|
|
1052
|
-
case 'AutomationSheetsNode':
|
|
1053
|
-
return await this.executeSheetsNode(node, inputData || null);
|
|
1054
|
-
case 'AutomationEndNode':
|
|
1055
|
-
return await this.executeEndNode(node, inputData || null);
|
|
1056
|
-
default:
|
|
1057
|
-
throw new Error(`Unknown node type: ${nodeType}`);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
public async executeWorkflow(): Promise<AutomationResult> {
|
|
1062
|
-
try {
|
|
1063
|
-
// Find start node
|
|
1064
|
-
const startNode = this.nodes.find(
|
|
1065
|
-
(node) => node.type === 'AutomationStartNode',
|
|
1066
|
-
);
|
|
1067
|
-
if (!startNode) {
|
|
1068
|
-
throw new Error('No start node found in workflow');
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
this.log('info', 'workflow', 'engine', 'Starting workflow execution');
|
|
1072
|
-
|
|
1073
|
-
// Execute nodes in sequence
|
|
1074
|
-
let currentNode: Node | null = startNode;
|
|
1075
|
-
let currentData: string | number | boolean | object | null = null;
|
|
1076
|
-
const visitedNodes = new Set<string>();
|
|
1077
|
-
|
|
1078
|
-
while (currentNode) {
|
|
1079
|
-
if (visitedNodes.has(currentNode.id)) {
|
|
1080
|
-
throw new Error(
|
|
1081
|
-
`Circular dependency detected: node ${currentNode.id} already visited`,
|
|
1082
|
-
);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
visitedNodes.add(currentNode.id);
|
|
1086
|
-
|
|
1087
|
-
// Announce node execution start
|
|
1088
|
-
this.log(
|
|
1089
|
-
'info',
|
|
1090
|
-
currentNode.id,
|
|
1091
|
-
String(currentNode.type || 'node'),
|
|
1092
|
-
'Executing node...',
|
|
1093
|
-
);
|
|
1094
|
-
|
|
1095
|
-
// Update node status to running
|
|
1096
|
-
(currentNode.data as Record<string, unknown>).status = 'Running';
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
(currentNode
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1
|
+
import { Node, Edge } from '@xyflow/react';
|
|
2
|
+
import {
|
|
3
|
+
AutomationNodeForm,
|
|
4
|
+
AutomationStartNodeForm,
|
|
5
|
+
AutomationApiNodeForm,
|
|
6
|
+
AutomationFormattingNodeForm,
|
|
7
|
+
AutomationSheetsNodeForm,
|
|
8
|
+
AutomationEndNodeForm,
|
|
9
|
+
} from '../types/automation-node-data-types';
|
|
10
|
+
import {
|
|
11
|
+
GoogleSheetsService,
|
|
12
|
+
SheetsExportOptions,
|
|
13
|
+
} from '../services/GoogleSheetsService';
|
|
14
|
+
import { SlackService } from '../services/SlackService';
|
|
15
|
+
import { TwilioWhatsAppService } from '../services/TwilioWhatsAppService';
|
|
16
|
+
|
|
17
|
+
export interface AutomationContext {
|
|
18
|
+
runId: string;
|
|
19
|
+
startTime: Date;
|
|
20
|
+
variables: Record<string, string | number | boolean | object | null>;
|
|
21
|
+
logs: AutomationLog[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AutomationLog {
|
|
25
|
+
timestamp: Date;
|
|
26
|
+
nodeId: string;
|
|
27
|
+
nodeType: string;
|
|
28
|
+
level: 'info' | 'warning' | 'error' | 'success';
|
|
29
|
+
message: string;
|
|
30
|
+
data?: string | number | boolean | object | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AutomationResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
context: AutomationContext;
|
|
36
|
+
finalOutput?: string | number | boolean | object | null;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AutomationExecutionEngine {
|
|
41
|
+
private nodes: Node[];
|
|
42
|
+
private edges: Edge[];
|
|
43
|
+
private context: AutomationContext;
|
|
44
|
+
private onNodeUpdate?: (nodeId: string, updatedData: any) => void;
|
|
45
|
+
private onLog?: (log: AutomationLog) => void;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
nodes: Node[],
|
|
49
|
+
edges: Edge[],
|
|
50
|
+
onNodeUpdate?: (nodeId: string, updatedData: any) => void,
|
|
51
|
+
onLog?: (log: AutomationLog) => void,
|
|
52
|
+
) {
|
|
53
|
+
this.nodes = nodes;
|
|
54
|
+
this.edges = edges;
|
|
55
|
+
this.onNodeUpdate = onNodeUpdate;
|
|
56
|
+
this.onLog = onLog;
|
|
57
|
+
this.context = {
|
|
58
|
+
runId: this.generateRunId(),
|
|
59
|
+
startTime: new Date(),
|
|
60
|
+
variables: {},
|
|
61
|
+
logs: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private generateRunId(): string {
|
|
66
|
+
return `run_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private storeExecutionResult(
|
|
70
|
+
nodeId: string,
|
|
71
|
+
result: string | number | boolean | object | null,
|
|
72
|
+
success: boolean = true,
|
|
73
|
+
error?: string,
|
|
74
|
+
executionTime?: number,
|
|
75
|
+
): void {
|
|
76
|
+
const node = this.nodes.find((n) => n.id === nodeId);
|
|
77
|
+
if (!node) return;
|
|
78
|
+
|
|
79
|
+
const executionResult = {
|
|
80
|
+
success,
|
|
81
|
+
data: result,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
error,
|
|
84
|
+
executionTime,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Update the node's formData with execution result
|
|
88
|
+
if (node.data.formData) {
|
|
89
|
+
(node.data.formData as any).executionResult = executionResult;
|
|
90
|
+
} else {
|
|
91
|
+
(node.data as any).executionResult = executionResult;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Notify the parent component about the node update
|
|
95
|
+
if (this.onNodeUpdate) {
|
|
96
|
+
this.onNodeUpdate(nodeId, node.data);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private log(
|
|
101
|
+
level: AutomationLog['level'],
|
|
102
|
+
nodeId: string,
|
|
103
|
+
nodeType: string,
|
|
104
|
+
message: string,
|
|
105
|
+
data?: string | number | boolean | object | null,
|
|
106
|
+
) {
|
|
107
|
+
const entry: AutomationLog = {
|
|
108
|
+
timestamp: new Date(),
|
|
109
|
+
nodeId,
|
|
110
|
+
nodeType,
|
|
111
|
+
level,
|
|
112
|
+
message,
|
|
113
|
+
data,
|
|
114
|
+
};
|
|
115
|
+
this.context.logs.push(entry);
|
|
116
|
+
if (this.onLog) {
|
|
117
|
+
try {
|
|
118
|
+
this.onLog(entry);
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore UI callback errors
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private getNextNodes(currentNodeId: string): Node[] {
|
|
126
|
+
const outgoingEdges = this.edges.filter(
|
|
127
|
+
(edge) => edge.source === currentNodeId,
|
|
128
|
+
);
|
|
129
|
+
const nextNodeIds = outgoingEdges.map((edge) => edge.target);
|
|
130
|
+
return this.nodes.filter((node) => nextNodeIds.includes(node.id));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async executeStartNode(
|
|
134
|
+
node: Node,
|
|
135
|
+
): Promise<{ runId: string; startTime: string }> {
|
|
136
|
+
const data = node.data;
|
|
137
|
+
this.log(
|
|
138
|
+
'info',
|
|
139
|
+
node.id,
|
|
140
|
+
'AutomationStartNode',
|
|
141
|
+
'Starting automation workflow',
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Set initial context variables
|
|
145
|
+
this.context.variables.runId = this.context.runId;
|
|
146
|
+
this.context.variables.startTime = this.context.startTime.toISOString();
|
|
147
|
+
|
|
148
|
+
const result = {
|
|
149
|
+
runId: this.context.runId,
|
|
150
|
+
startTime: this.context.startTime.toISOString(),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Store the execution result in node data
|
|
154
|
+
this.storeExecutionResult(node.id, result, true);
|
|
155
|
+
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async executeApiNode(node: Node): Promise<object> {
|
|
160
|
+
const data = node.data;
|
|
161
|
+
const formData = (data.formData || data) as AutomationApiNodeForm;
|
|
162
|
+
|
|
163
|
+
this.log(
|
|
164
|
+
'info',
|
|
165
|
+
node.id,
|
|
166
|
+
'AutomationApiNode',
|
|
167
|
+
`Making ${formData.method} request to ${formData.url}`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const headers: Record<string, string> = {};
|
|
172
|
+
if (formData.headers && Array.isArray(formData.headers)) {
|
|
173
|
+
formData.headers.forEach((header) => {
|
|
174
|
+
if (header.enabled) {
|
|
175
|
+
headers[header.key] = header.value;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const queryParams = new URLSearchParams();
|
|
181
|
+
if (formData.queryParams && Array.isArray(formData.queryParams)) {
|
|
182
|
+
formData.queryParams.forEach((param) => {
|
|
183
|
+
if (param.enabled) {
|
|
184
|
+
queryParams.append(param.key, param.value);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const url = queryParams.toString()
|
|
190
|
+
? `${formData.url}?${queryParams.toString()}`
|
|
191
|
+
: formData.url;
|
|
192
|
+
|
|
193
|
+
const requestOptions: RequestInit = {
|
|
194
|
+
method: formData.method,
|
|
195
|
+
headers,
|
|
196
|
+
signal: AbortSignal.timeout(formData.timeout || 30000),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
formData.body &&
|
|
201
|
+
(formData.method === 'POST' || formData.method === 'PUT')
|
|
202
|
+
) {
|
|
203
|
+
requestOptions.body = formData.body;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const response = await fetch(url, requestOptions);
|
|
207
|
+
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`HTTP ${response.status}: ${response.statusText} - ${url}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const responseData = await response.json();
|
|
215
|
+
this.log(
|
|
216
|
+
'success',
|
|
217
|
+
node.id,
|
|
218
|
+
'AutomationApiNode',
|
|
219
|
+
'API call completed successfully',
|
|
220
|
+
responseData,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Store the execution result in node data
|
|
224
|
+
this.storeExecutionResult(node.id, responseData, true);
|
|
225
|
+
|
|
226
|
+
return responseData;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
let errorMessage = `API call failed: ${error}`;
|
|
229
|
+
|
|
230
|
+
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
231
|
+
errorMessage = `Network error: Unable to connect to ${formData.url}. This might be due to CORS restrictions or network connectivity issues.`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.log(
|
|
235
|
+
'error',
|
|
236
|
+
node.id,
|
|
237
|
+
'AutomationApiNode',
|
|
238
|
+
errorMessage,
|
|
239
|
+
error instanceof Error ? error.message : String(error),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Store the error result in node data
|
|
243
|
+
this.storeExecutionResult(node.id, null, false, errorMessage);
|
|
244
|
+
|
|
245
|
+
throw new Error(errorMessage);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async executeFormattingNode(
|
|
250
|
+
node: Node,
|
|
251
|
+
inputData: string | number | boolean | object | null,
|
|
252
|
+
): Promise<string | number | boolean | object | null> {
|
|
253
|
+
const data = node.data;
|
|
254
|
+
const formData = (data.formData || data) as AutomationFormattingNodeForm;
|
|
255
|
+
|
|
256
|
+
this.log(
|
|
257
|
+
'info',
|
|
258
|
+
node.id,
|
|
259
|
+
'AutomationFormattingNode',
|
|
260
|
+
'Starting data formatting',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
if (formData.formattingType === 'ai-powered' && formData.aiFormatting) {
|
|
265
|
+
// AI-powered formatting
|
|
266
|
+
this.log('info', node.id, 'AutomationFormattingNode', 'AI thinking...');
|
|
267
|
+
const aiResponse = await this.callAiApi(
|
|
268
|
+
formData.aiFormatting,
|
|
269
|
+
inputData,
|
|
270
|
+
);
|
|
271
|
+
this.log(
|
|
272
|
+
'success',
|
|
273
|
+
node.id,
|
|
274
|
+
'AutomationFormattingNode',
|
|
275
|
+
'AI formatting completed',
|
|
276
|
+
aiResponse,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Store the execution result in node data
|
|
280
|
+
this.storeExecutionResult(node.id, aiResponse, true);
|
|
281
|
+
|
|
282
|
+
return aiResponse;
|
|
283
|
+
} else {
|
|
284
|
+
// Basic formatting
|
|
285
|
+
const formattedData = this.basicFormatting(inputData, formData);
|
|
286
|
+
this.log(
|
|
287
|
+
'success',
|
|
288
|
+
node.id,
|
|
289
|
+
'AutomationFormattingNode',
|
|
290
|
+
'Basic formatting completed',
|
|
291
|
+
formattedData,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Store the execution result in node data
|
|
295
|
+
this.storeExecutionResult(node.id, formattedData, true);
|
|
296
|
+
|
|
297
|
+
return formattedData;
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
this.log(
|
|
301
|
+
'error',
|
|
302
|
+
node.id,
|
|
303
|
+
'AutomationFormattingNode',
|
|
304
|
+
`Formatting failed: ${error}`,
|
|
305
|
+
error instanceof Error ? error.message : String(error),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Store the error result in node data
|
|
309
|
+
this.storeExecutionResult(
|
|
310
|
+
node.id,
|
|
311
|
+
null,
|
|
312
|
+
false,
|
|
313
|
+
error instanceof Error ? error.message : String(error),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private async callAiApi(
|
|
321
|
+
aiConfig: NonNullable<AutomationFormattingNodeForm['aiFormatting']>,
|
|
322
|
+
inputData: string | number | boolean | object | null,
|
|
323
|
+
): Promise<string | number | boolean | object | null> {
|
|
324
|
+
// Validate API key is not a placeholder
|
|
325
|
+
if (
|
|
326
|
+
!aiConfig.apiKey ||
|
|
327
|
+
aiConfig.apiKey.includes('your-openai-api-key-here') ||
|
|
328
|
+
aiConfig.apiKey.length < 10
|
|
329
|
+
) {
|
|
330
|
+
throw new Error('Invalid API key. Please provide a valid API key.');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Additional validation for Gemini API key format
|
|
334
|
+
if (aiConfig.apiUrl.includes('generativelanguage.googleapis.com')) {
|
|
335
|
+
if (!aiConfig.apiKey.startsWith('AIza')) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
'Invalid Gemini API key format. Gemini API keys should start with "AIza".',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const prompt = `${aiConfig.instruction}\n\nInput data: ${JSON.stringify(inputData)}`;
|
|
343
|
+
|
|
344
|
+
// Determine if this is Gemini API or OpenAI API based on URL
|
|
345
|
+
const isGeminiApi = aiConfig.apiUrl.includes(
|
|
346
|
+
'generativelanguage.googleapis.com',
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
let requestBody: any;
|
|
350
|
+
let headers: Record<string, string>;
|
|
351
|
+
|
|
352
|
+
if (isGeminiApi) {
|
|
353
|
+
// Gemini API format
|
|
354
|
+
headers = {
|
|
355
|
+
'Content-Type': 'application/json',
|
|
356
|
+
'X-goog-api-key': aiConfig.apiKey,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
requestBody = {
|
|
360
|
+
contents: [
|
|
361
|
+
{
|
|
362
|
+
parts: [
|
|
363
|
+
{
|
|
364
|
+
text: `${aiConfig.systemPrompt || 'You are a data formatting assistant.'}\n\n${prompt}`,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
generationConfig: {
|
|
370
|
+
temperature: aiConfig.temperature || 0.1,
|
|
371
|
+
maxOutputTokens: aiConfig.maxTokens || 1000,
|
|
372
|
+
responseMimeType: 'application/json',
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
} else {
|
|
376
|
+
// OpenAI API format
|
|
377
|
+
headers = {
|
|
378
|
+
'Content-Type': 'application/json',
|
|
379
|
+
Authorization: `Bearer ${aiConfig.apiKey}`,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
requestBody = {
|
|
383
|
+
model: aiConfig.model,
|
|
384
|
+
messages: [
|
|
385
|
+
{
|
|
386
|
+
role: 'system',
|
|
387
|
+
content:
|
|
388
|
+
aiConfig.systemPrompt || 'You are a data formatting assistant.',
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
role: 'user',
|
|
392
|
+
content: prompt,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
temperature: aiConfig.temperature || 0.1,
|
|
396
|
+
max_tokens: aiConfig.maxTokens || 1000,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const response = await fetch(aiConfig.apiUrl, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers,
|
|
403
|
+
body: JSON.stringify(requestBody),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
const errorText = await response.text();
|
|
408
|
+
throw new Error(
|
|
409
|
+
`AI API call failed: ${response.status} ${response.statusText}. ${errorText}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = await response.json();
|
|
414
|
+
|
|
415
|
+
let content: string;
|
|
416
|
+
|
|
417
|
+
if (isGeminiApi) {
|
|
418
|
+
// Gemini API response format
|
|
419
|
+
if (
|
|
420
|
+
!result.candidates ||
|
|
421
|
+
!result.candidates[0] ||
|
|
422
|
+
!result.candidates[0].content ||
|
|
423
|
+
!result.candidates[0].content.parts
|
|
424
|
+
) {
|
|
425
|
+
throw new Error('Invalid Gemini API response format');
|
|
426
|
+
}
|
|
427
|
+
content = result.candidates[0].content.parts[0].text;
|
|
428
|
+
} else {
|
|
429
|
+
// OpenAI API response format
|
|
430
|
+
if (!result.choices || !result.choices[0] || !result.choices[0].message) {
|
|
431
|
+
throw new Error('Invalid AI API response format');
|
|
432
|
+
}
|
|
433
|
+
content = result.choices[0].message.content;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Try to parse as JSON, fallback to string if parsing fails
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(content);
|
|
439
|
+
} catch {
|
|
440
|
+
return content;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private basicFormatting(
|
|
445
|
+
inputData: string | number | boolean | object | null,
|
|
446
|
+
config: AutomationFormattingNodeForm,
|
|
447
|
+
): string | number | boolean | object | null {
|
|
448
|
+
// Basic data transformation logic
|
|
449
|
+
if (typeof inputData === 'string') {
|
|
450
|
+
return inputData.trim();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (Array.isArray(inputData)) {
|
|
454
|
+
return inputData.map((item) => this.basicFormatting(item, config));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof inputData === 'object' && inputData !== null) {
|
|
458
|
+
const formatted: Record<string, unknown> = {};
|
|
459
|
+
Object.keys(inputData).forEach((key) => {
|
|
460
|
+
formatted[key] = this.basicFormatting(
|
|
461
|
+
(inputData as Record<string, unknown>)[key] as
|
|
462
|
+
| string
|
|
463
|
+
| number
|
|
464
|
+
| boolean
|
|
465
|
+
| object
|
|
466
|
+
| null,
|
|
467
|
+
config,
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
return formatted;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return inputData;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async executeSheetsNode(
|
|
477
|
+
node: Node,
|
|
478
|
+
inputData: string | number | boolean | object | null,
|
|
479
|
+
): Promise<string | number | boolean | object | null> {
|
|
480
|
+
const data = node.data;
|
|
481
|
+
const formData = (data.formData || data) as AutomationSheetsNodeForm;
|
|
482
|
+
|
|
483
|
+
this.log(
|
|
484
|
+
'info',
|
|
485
|
+
node.id,
|
|
486
|
+
'AutomationSheetsNode',
|
|
487
|
+
'Starting Google Sheets export',
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
// Initialize/derive per-output-method statuses container
|
|
492
|
+
const outputStatuses: any = (data as any).outputStatuses || {};
|
|
493
|
+
// Only set Google Sheets status when exportFormat includes sheets
|
|
494
|
+
if (
|
|
495
|
+
formData.exportOptions?.exportFormat === 'sheets' ||
|
|
496
|
+
formData.exportOptions?.exportFormat === 'both'
|
|
497
|
+
) {
|
|
498
|
+
outputStatuses.googleSheets = 'running';
|
|
499
|
+
}
|
|
500
|
+
// Gmail runs only if enabled
|
|
501
|
+
if (formData.exportOptions?.emailSendEnabled) {
|
|
502
|
+
outputStatuses.gmail = 'running';
|
|
503
|
+
}
|
|
504
|
+
// Slack runs only if enabled
|
|
505
|
+
if (formData.exportOptions?.slack?.enabled) {
|
|
506
|
+
outputStatuses.slack = 'running';
|
|
507
|
+
}
|
|
508
|
+
// WhatsApp runs only if enabled
|
|
509
|
+
if (formData.exportOptions?.whatsapp?.enabled) {
|
|
510
|
+
outputStatuses.whatsapp = 'running';
|
|
511
|
+
}
|
|
512
|
+
(data as any).outputStatuses = outputStatuses;
|
|
513
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
514
|
+
|
|
515
|
+
const sheetsConfig = formData.sheetsConfig;
|
|
516
|
+
const dataMapping = formData.dataMapping;
|
|
517
|
+
const exportOptions = formData.exportOptions;
|
|
518
|
+
const wantsSheets =
|
|
519
|
+
exportOptions?.exportFormat === 'sheets' ||
|
|
520
|
+
exportOptions?.exportFormat === 'both';
|
|
521
|
+
const wantsExcel =
|
|
522
|
+
exportOptions?.exportFormat === 'excel' ||
|
|
523
|
+
exportOptions?.exportFormat === 'both';
|
|
524
|
+
const wantsGmail = Boolean(exportOptions?.emailSendEnabled);
|
|
525
|
+
|
|
526
|
+
const sheetsService = new GoogleSheetsService();
|
|
527
|
+
// Only perform Google auth/validation when Sheets or Gmail is in use
|
|
528
|
+
if (wantsSheets || wantsGmail) {
|
|
529
|
+
this.log(
|
|
530
|
+
'info',
|
|
531
|
+
node.id,
|
|
532
|
+
'AutomationSheetsNode',
|
|
533
|
+
'Authenticating with Google (Sheets/Gmail)...',
|
|
534
|
+
);
|
|
535
|
+
// If Gmail send is enabled and we're using OAuth client, request both scopes in one consent
|
|
536
|
+
try {
|
|
537
|
+
const creds: any = sheetsConfig?.credentials || {};
|
|
538
|
+
if (creds.type === 'oauth' && (creds.clientId || creds.oauthToken)) {
|
|
539
|
+
const neededScopes = [
|
|
540
|
+
'https://www.googleapis.com/auth/spreadsheets',
|
|
541
|
+
];
|
|
542
|
+
if (exportOptions?.emailSendEnabled) {
|
|
543
|
+
neededScopes.push('https://www.googleapis.com/auth/gmail.send');
|
|
544
|
+
}
|
|
545
|
+
const existing = Array.isArray(creds.scopes) ? creds.scopes : [];
|
|
546
|
+
const union = Array.from(new Set([...existing, ...neededScopes]));
|
|
547
|
+
creds.scopes = union;
|
|
548
|
+
sheetsConfig.credentials = creds;
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
// Non-fatal; initialization will still proceed with default scopes
|
|
552
|
+
}
|
|
553
|
+
const validation = sheetsService.validateConfig(sheetsConfig);
|
|
554
|
+
if (!validation.valid) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Configuration validation failed: ${validation.errors.join(', ')}`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Prepare data for export (only when exporting to Sheets/Excel)
|
|
562
|
+
let exportData: any[] = [];
|
|
563
|
+
if (wantsSheets || wantsExcel) {
|
|
564
|
+
if (Array.isArray(inputData)) {
|
|
565
|
+
exportData = inputData;
|
|
566
|
+
} else if (inputData && typeof inputData === 'object') {
|
|
567
|
+
exportData = [inputData];
|
|
568
|
+
} else {
|
|
569
|
+
throw new Error('Invalid input data format for export');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Initialize Google Sheets service only when using Sheets or Gmail
|
|
574
|
+
if (wantsSheets || wantsGmail) {
|
|
575
|
+
await sheetsService.initialize(sheetsConfig);
|
|
576
|
+
this.log(
|
|
577
|
+
'success',
|
|
578
|
+
node.id,
|
|
579
|
+
'AutomationSheetsNode',
|
|
580
|
+
'Authenticated with Google APIs (Sheets/Gmail)',
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Prepare export options
|
|
585
|
+
const sheetsExportOptions: SheetsExportOptions = {
|
|
586
|
+
emailRecipients: exportOptions.emailRecipients,
|
|
587
|
+
fileName: exportOptions.fileName,
|
|
588
|
+
exportFormat:
|
|
589
|
+
exportOptions.exportFormat === 'both'
|
|
590
|
+
? 'sheets'
|
|
591
|
+
: exportOptions.exportFormat,
|
|
592
|
+
includeHeaders: exportOptions.includeHeaders,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
let response: any = null;
|
|
596
|
+
|
|
597
|
+
// Export to Google Sheets
|
|
598
|
+
if (
|
|
599
|
+
exportOptions?.exportFormat === 'sheets' ||
|
|
600
|
+
exportOptions?.exportFormat === 'both'
|
|
601
|
+
) {
|
|
602
|
+
this.log(
|
|
603
|
+
'info',
|
|
604
|
+
node.id,
|
|
605
|
+
'AutomationSheetsNode',
|
|
606
|
+
'Writing data to Google Sheets...',
|
|
607
|
+
);
|
|
608
|
+
response = await sheetsService.exportToSheets(
|
|
609
|
+
exportData,
|
|
610
|
+
sheetsConfig,
|
|
611
|
+
dataMapping,
|
|
612
|
+
sheetsExportOptions,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
this.log(
|
|
616
|
+
'success',
|
|
617
|
+
node.id,
|
|
618
|
+
'AutomationSheetsNode',
|
|
619
|
+
`Successfully exported ${response.rowsAdded} rows to Google Sheets`,
|
|
620
|
+
response,
|
|
621
|
+
);
|
|
622
|
+
// Mark GS connected on success
|
|
623
|
+
outputStatuses.googleSheets = 'connected';
|
|
624
|
+
(data as any).outputStatuses = outputStatuses;
|
|
625
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Export to Excel
|
|
629
|
+
if (
|
|
630
|
+
exportOptions.exportFormat === 'excel' ||
|
|
631
|
+
exportOptions.exportFormat === 'both'
|
|
632
|
+
) {
|
|
633
|
+
this.log(
|
|
634
|
+
'info',
|
|
635
|
+
node.id,
|
|
636
|
+
'AutomationSheetsNode',
|
|
637
|
+
'Exporting data to Excel...',
|
|
638
|
+
);
|
|
639
|
+
const excelResponse = await sheetsService.exportToExcel(
|
|
640
|
+
exportData,
|
|
641
|
+
dataMapping,
|
|
642
|
+
sheetsExportOptions,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
this.log(
|
|
646
|
+
'success',
|
|
647
|
+
node.id,
|
|
648
|
+
'AutomationSheetsNode',
|
|
649
|
+
`Successfully exported ${excelResponse.rowsAdded} rows to Excel`,
|
|
650
|
+
excelResponse,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// If we're doing both, merge the responses
|
|
654
|
+
if (exportOptions.exportFormat === 'both') {
|
|
655
|
+
response = {
|
|
656
|
+
...response,
|
|
657
|
+
excelExport: excelResponse,
|
|
658
|
+
combinedSuccess: response.success && excelResponse.success,
|
|
659
|
+
};
|
|
660
|
+
} else {
|
|
661
|
+
response = excelResponse;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Optional email send (via mailto fallback or Gmail API if implemented)
|
|
666
|
+
if (exportOptions.emailSendEnabled) {
|
|
667
|
+
const emailRecipients = exportOptions.emailRecipients || [];
|
|
668
|
+
if (emailRecipients.length > 0) {
|
|
669
|
+
this.log(
|
|
670
|
+
'info',
|
|
671
|
+
node.id,
|
|
672
|
+
'AutomationSheetsNode',
|
|
673
|
+
`Sending email to ${emailRecipients.length} recipient(s)...`,
|
|
674
|
+
);
|
|
675
|
+
const emailSent = await sheetsService.sendEmail({
|
|
676
|
+
from: exportOptions.emailSender,
|
|
677
|
+
to: emailRecipients,
|
|
678
|
+
subject: exportOptions.emailSubject || 'Automation Output',
|
|
679
|
+
message: exportOptions.emailMessage,
|
|
680
|
+
spreadsheetUrl:
|
|
681
|
+
exportOptions.includeSpreadsheetLink &&
|
|
682
|
+
(response as any)?.spreadsheetUrl
|
|
683
|
+
? (response as any).spreadsheetUrl
|
|
684
|
+
: undefined,
|
|
685
|
+
// Note: attachExcel via Gmail requires Gmail scope and MIME building;
|
|
686
|
+
// mailto cannot attach files. This is a placeholder hook.
|
|
687
|
+
attachExcelBlob: undefined,
|
|
688
|
+
attachExcelFileName: sheetsExportOptions.fileName,
|
|
689
|
+
// Critical: disable UI fallback to ensure fully automated send
|
|
690
|
+
disableUiFallback: true,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
if (emailSent) {
|
|
694
|
+
this.log(
|
|
695
|
+
'success',
|
|
696
|
+
node.id,
|
|
697
|
+
'AutomationSheetsNode',
|
|
698
|
+
`Email sent to ${emailRecipients.length} recipient(s)`,
|
|
699
|
+
);
|
|
700
|
+
outputStatuses.gmail = 'connected';
|
|
701
|
+
} else {
|
|
702
|
+
this.log(
|
|
703
|
+
'error',
|
|
704
|
+
node.id,
|
|
705
|
+
'AutomationSheetsNode',
|
|
706
|
+
'Email send failed (API)',
|
|
707
|
+
);
|
|
708
|
+
outputStatuses.gmail = 'failed';
|
|
709
|
+
}
|
|
710
|
+
(data as any).outputStatuses = outputStatuses;
|
|
711
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Optional Slack send via webhook
|
|
716
|
+
if (exportOptions.slack?.enabled) {
|
|
717
|
+
const slackService = new SlackService();
|
|
718
|
+
const validation = slackService.validateConfig(exportOptions.slack);
|
|
719
|
+
if (!validation.valid) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`Slack configuration invalid: ${validation.errors.join(', ')}`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Build message text with simple token replacements
|
|
726
|
+
const template =
|
|
727
|
+
exportOptions.slack.messageTemplate ||
|
|
728
|
+
'Automation update for {{fileName}}';
|
|
729
|
+
const contextVars: Record<string, any> = {
|
|
730
|
+
fileName: exportOptions.fileName,
|
|
731
|
+
rowsAdded: (response as any)?.rowsAdded,
|
|
732
|
+
sheetName:
|
|
733
|
+
(response as any)?.sheetName || formData.sheetsConfig?.sheetName,
|
|
734
|
+
};
|
|
735
|
+
const messageText = template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
|
|
736
|
+
key in contextVars ? String(contextVars[key]) : '',
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
// Prefer spreadsheetUrl from Sheets response if available and flag set
|
|
740
|
+
const spreadsheetUrl = exportOptions.slack.includeSpreadsheetLink
|
|
741
|
+
? (response as any)?.spreadsheetUrl || undefined
|
|
742
|
+
: undefined;
|
|
743
|
+
|
|
744
|
+
const payload = slackService.buildPayload(
|
|
745
|
+
exportOptions.slack,
|
|
746
|
+
messageText,
|
|
747
|
+
inputData,
|
|
748
|
+
spreadsheetUrl,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
this.log(
|
|
753
|
+
'info',
|
|
754
|
+
node.id,
|
|
755
|
+
'AutomationSheetsNode',
|
|
756
|
+
`Sending Slack message to #${exportOptions.slack.channel}...`,
|
|
757
|
+
);
|
|
758
|
+
await slackService.sendMessage({
|
|
759
|
+
webhookUrl: exportOptions.slack.webhookUrl,
|
|
760
|
+
payload,
|
|
761
|
+
});
|
|
762
|
+
this.log(
|
|
763
|
+
'success',
|
|
764
|
+
node.id,
|
|
765
|
+
'AutomationSheetsNode',
|
|
766
|
+
`Slack message sent to #${exportOptions.slack.channel}`,
|
|
767
|
+
);
|
|
768
|
+
outputStatuses.slack = 'connected';
|
|
769
|
+
(data as any).outputStatuses = outputStatuses;
|
|
770
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
771
|
+
} catch (slackErr) {
|
|
772
|
+
const msg =
|
|
773
|
+
slackErr instanceof Error ? slackErr.message : String(slackErr);
|
|
774
|
+
this.log(
|
|
775
|
+
'error',
|
|
776
|
+
node.id,
|
|
777
|
+
'AutomationSheetsNode',
|
|
778
|
+
`Slack send failed: ${msg}`,
|
|
779
|
+
);
|
|
780
|
+
// Do not fail the whole workflow due to Slack CORS; mark Slack as failed but continue
|
|
781
|
+
outputStatuses.slack = 'failed';
|
|
782
|
+
(data as any).outputStatuses = outputStatuses;
|
|
783
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Optional WhatsApp send
|
|
788
|
+
if (exportOptions.whatsapp?.enabled) {
|
|
789
|
+
try {
|
|
790
|
+
this.log(
|
|
791
|
+
'info',
|
|
792
|
+
node.id,
|
|
793
|
+
'AutomationSheetsNode',
|
|
794
|
+
`Sending WhatsApp message to ${exportOptions.whatsapp.phoneNumber}...`,
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
// Build WhatsApp message with template replacements
|
|
798
|
+
const template =
|
|
799
|
+
exportOptions.whatsapp.messageTemplate ||
|
|
800
|
+
'Automation Result: {{fileName}} completed successfully. Rows processed: {{rowsAdded}}.';
|
|
801
|
+
const contextVars: Record<string, any> = {
|
|
802
|
+
fileName: exportOptions.fileName,
|
|
803
|
+
rowsAdded: (response as any)?.rowsAdded,
|
|
804
|
+
sheetName:
|
|
805
|
+
(response as any)?.sheetName || formData.sheetsConfig?.sheetName,
|
|
806
|
+
timestamp: new Date().toLocaleString(),
|
|
807
|
+
};
|
|
808
|
+
const messageText = template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
|
|
809
|
+
key in contextVars ? String(contextVars[key]) : '',
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
// Create WhatsApp message with execution data
|
|
813
|
+
const whatsappMessage = `
|
|
814
|
+
Automation Result:
|
|
815
|
+
------------------
|
|
816
|
+
${messageText}
|
|
817
|
+
|
|
818
|
+
Execution Details:
|
|
819
|
+
- Run ID: ${this.context.runId}
|
|
820
|
+
- Start Time: ${this.context.startTime.toLocaleString()}
|
|
821
|
+
- Status: Completed Successfully
|
|
822
|
+
- Data: ${JSON.stringify(inputData, null, 2)}
|
|
823
|
+
`;
|
|
824
|
+
|
|
825
|
+
// Check if Twilio is configured
|
|
826
|
+
if (exportOptions.whatsapp.twilio?.enabled) {
|
|
827
|
+
// Use Twilio WhatsApp API
|
|
828
|
+
const twilioService = new TwilioWhatsAppService({
|
|
829
|
+
accountSid: exportOptions.whatsapp.twilio.accountSid,
|
|
830
|
+
authToken: exportOptions.whatsapp.twilio.authToken,
|
|
831
|
+
fromNumber: exportOptions.whatsapp.twilio.fromNumber,
|
|
832
|
+
isSandbox: exportOptions.whatsapp.twilio.isSandbox,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Validate Twilio configuration
|
|
836
|
+
const validation = twilioService.validateConfig();
|
|
837
|
+
if (!validation.valid) {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`Twilio configuration invalid: ${validation.errors.join(', ')}`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Send message via Twilio
|
|
844
|
+
|
|
845
|
+
const twilioResult = await twilioService.sendMessage({
|
|
846
|
+
to: exportOptions.whatsapp.phoneNumber,
|
|
847
|
+
message: whatsappMessage.trim(),
|
|
848
|
+
templateSid: exportOptions.whatsapp.twilio.templateSid,
|
|
849
|
+
templateVariables:
|
|
850
|
+
exportOptions.whatsapp.twilio.templateVariables,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (twilioResult.success) {
|
|
854
|
+
this.log(
|
|
855
|
+
'success',
|
|
856
|
+
node.id,
|
|
857
|
+
'AutomationSheetsNode',
|
|
858
|
+
`WhatsApp message sent via Twilio (SID: ${twilioResult.messageSid})`,
|
|
859
|
+
);
|
|
860
|
+
outputStatuses.whatsapp = 'connected';
|
|
861
|
+
} else {
|
|
862
|
+
throw new Error(`Twilio send failed: ${twilioResult.error}`);
|
|
863
|
+
}
|
|
864
|
+
} else {
|
|
865
|
+
// Fallback to WhatsApp Web (existing behavior)
|
|
866
|
+
const encodedMsg = encodeURIComponent(whatsappMessage.trim());
|
|
867
|
+
const whatsappUrl = `https://wa.me/${exportOptions.whatsapp.phoneNumber}?text=${encodedMsg}`;
|
|
868
|
+
|
|
869
|
+
// Open WhatsApp in a new tab
|
|
870
|
+
window.open(whatsappUrl, '_blank');
|
|
871
|
+
|
|
872
|
+
this.log(
|
|
873
|
+
'success',
|
|
874
|
+
node.id,
|
|
875
|
+
'AutomationSheetsNode',
|
|
876
|
+
`WhatsApp message opened for ${exportOptions.whatsapp.phoneNumber}`,
|
|
877
|
+
);
|
|
878
|
+
outputStatuses.whatsapp = 'connected';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
(data as any).outputStatuses = outputStatuses;
|
|
882
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
883
|
+
} catch (whatsappErr) {
|
|
884
|
+
const msg =
|
|
885
|
+
whatsappErr instanceof Error
|
|
886
|
+
? whatsappErr.message
|
|
887
|
+
: String(whatsappErr);
|
|
888
|
+
this.log(
|
|
889
|
+
'error',
|
|
890
|
+
node.id,
|
|
891
|
+
'AutomationSheetsNode',
|
|
892
|
+
`WhatsApp send failed: ${msg}`,
|
|
893
|
+
);
|
|
894
|
+
outputStatuses.whatsapp = 'failed';
|
|
895
|
+
(data as any).outputStatuses = outputStatuses;
|
|
896
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, data);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Store the execution result in node data
|
|
901
|
+
this.storeExecutionResult(node.id, response, true);
|
|
902
|
+
|
|
903
|
+
return response;
|
|
904
|
+
} catch (error) {
|
|
905
|
+
const errorMessage =
|
|
906
|
+
error instanceof Error ? error.message : 'Unknown error';
|
|
907
|
+
this.log(
|
|
908
|
+
'error',
|
|
909
|
+
node.id,
|
|
910
|
+
'AutomationSheetsNode',
|
|
911
|
+
`Google Sheets export failed: ${errorMessage}`,
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
// Mark failing outputs
|
|
915
|
+
const dataRef: any = node.data;
|
|
916
|
+
dataRef.outputStatuses = dataRef.outputStatuses || {};
|
|
917
|
+
// If config missing -> not-set should be turned failed on workflow failure
|
|
918
|
+
const sheetsCfg =
|
|
919
|
+
dataRef.formData?.sheetsConfig || dataRef.sheetsConfig || {};
|
|
920
|
+
const creds = sheetsCfg.credentials || {};
|
|
921
|
+
const gsHasRequired = Boolean(
|
|
922
|
+
(sheetsCfg.spreadsheetId || creds.clientId) &&
|
|
923
|
+
sheetsCfg.sheetName &&
|
|
924
|
+
creds.type,
|
|
925
|
+
);
|
|
926
|
+
dataRef.outputStatuses.googleSheets = gsHasRequired ? 'failed' : 'failed';
|
|
927
|
+
|
|
928
|
+
const ex = dataRef.formData?.exportOptions || dataRef.exportOptions || {};
|
|
929
|
+
const gmHasRequired = Boolean(
|
|
930
|
+
ex.emailSendEnabled &&
|
|
931
|
+
ex.emailSender &&
|
|
932
|
+
(ex.emailRecipients?.length || 0) > 0 &&
|
|
933
|
+
ex.emailSubject &&
|
|
934
|
+
ex.emailMessage,
|
|
935
|
+
);
|
|
936
|
+
if (ex.emailSendEnabled) {
|
|
937
|
+
dataRef.outputStatuses.gmail = gmHasRequired ? 'failed' : 'failed';
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const sl = ex.slack || {};
|
|
941
|
+
const slackHasRequired = Boolean(
|
|
942
|
+
sl.enabled && sl.webhookUrl && sl.channel,
|
|
943
|
+
);
|
|
944
|
+
if (sl.enabled) {
|
|
945
|
+
dataRef.outputStatuses.slack = slackHasRequired ? 'failed' : 'failed';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const wa = ex.whatsapp || {};
|
|
949
|
+
const whatsappHasRequired = Boolean(wa.enabled && wa.phoneNumber);
|
|
950
|
+
if (wa.enabled) {
|
|
951
|
+
dataRef.outputStatuses.whatsapp = whatsappHasRequired
|
|
952
|
+
? 'failed'
|
|
953
|
+
: 'failed';
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (this.onNodeUpdate) this.onNodeUpdate(node.id, dataRef);
|
|
957
|
+
|
|
958
|
+
// Store the execution result in node data
|
|
959
|
+
this.storeExecutionResult(node.id, null, false, errorMessage);
|
|
960
|
+
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private getNestedValue(obj: any, path: string): any {
|
|
966
|
+
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private formatValueForSheets(value: any, dataType: string): any {
|
|
970
|
+
if (value === null || value === undefined) {
|
|
971
|
+
return '';
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
switch (dataType) {
|
|
975
|
+
case 'string':
|
|
976
|
+
return String(value);
|
|
977
|
+
case 'number':
|
|
978
|
+
return Number(value);
|
|
979
|
+
case 'date':
|
|
980
|
+
return new Date(value).toISOString();
|
|
981
|
+
case 'boolean':
|
|
982
|
+
return Boolean(value);
|
|
983
|
+
default:
|
|
984
|
+
return String(value);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private async executeEndNode(
|
|
989
|
+
node: Node,
|
|
990
|
+
inputData: string | number | boolean | object | null,
|
|
991
|
+
): Promise<string | number | boolean | object | null> {
|
|
992
|
+
const data = node.data;
|
|
993
|
+
const formData = (data.formData || data) as AutomationEndNodeForm;
|
|
994
|
+
|
|
995
|
+
this.log(
|
|
996
|
+
'info',
|
|
997
|
+
node.id,
|
|
998
|
+
'AutomationEndNode',
|
|
999
|
+
'Workflow completed successfully',
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
// Handle different output types
|
|
1003
|
+
switch (formData.outputType) {
|
|
1004
|
+
case 'display':
|
|
1005
|
+
this.log(
|
|
1006
|
+
'info',
|
|
1007
|
+
node.id,
|
|
1008
|
+
'AutomationEndNode',
|
|
1009
|
+
'Output displayed in UI',
|
|
1010
|
+
inputData,
|
|
1011
|
+
);
|
|
1012
|
+
break;
|
|
1013
|
+
case 'store':
|
|
1014
|
+
this.log(
|
|
1015
|
+
'info',
|
|
1016
|
+
node.id,
|
|
1017
|
+
'AutomationEndNode',
|
|
1018
|
+
'Data stored to destination',
|
|
1019
|
+
inputData,
|
|
1020
|
+
);
|
|
1021
|
+
break;
|
|
1022
|
+
case 'send':
|
|
1023
|
+
this.log(
|
|
1024
|
+
'info',
|
|
1025
|
+
node.id,
|
|
1026
|
+
'AutomationEndNode',
|
|
1027
|
+
'Data sent to destination',
|
|
1028
|
+
inputData,
|
|
1029
|
+
);
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Store the execution result in node data
|
|
1034
|
+
this.storeExecutionResult(node.id, inputData, true);
|
|
1035
|
+
|
|
1036
|
+
return inputData;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private async executeNode(
|
|
1040
|
+
node: Node,
|
|
1041
|
+
inputData?: string | number | boolean | object | null,
|
|
1042
|
+
): Promise<string | number | boolean | object | null> {
|
|
1043
|
+
const nodeType = node.type;
|
|
1044
|
+
|
|
1045
|
+
switch (nodeType) {
|
|
1046
|
+
case 'AutomationStartNode':
|
|
1047
|
+
return await this.executeStartNode(node);
|
|
1048
|
+
case 'AutomationApiNode':
|
|
1049
|
+
return await this.executeApiNode(node);
|
|
1050
|
+
case 'AutomationFormattingNode':
|
|
1051
|
+
return await this.executeFormattingNode(node, inputData || null);
|
|
1052
|
+
case 'AutomationSheetsNode':
|
|
1053
|
+
return await this.executeSheetsNode(node, inputData || null);
|
|
1054
|
+
case 'AutomationEndNode':
|
|
1055
|
+
return await this.executeEndNode(node, inputData || null);
|
|
1056
|
+
default:
|
|
1057
|
+
throw new Error(`Unknown node type: ${nodeType}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
public async executeWorkflow(): Promise<AutomationResult> {
|
|
1062
|
+
try {
|
|
1063
|
+
// Find start node
|
|
1064
|
+
const startNode = this.nodes.find(
|
|
1065
|
+
(node) => node.type === 'AutomationStartNode',
|
|
1066
|
+
);
|
|
1067
|
+
if (!startNode) {
|
|
1068
|
+
throw new Error('No start node found in workflow');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
this.log('info', 'workflow', 'engine', 'Starting workflow execution');
|
|
1072
|
+
|
|
1073
|
+
// Execute nodes in sequence
|
|
1074
|
+
let currentNode: Node | null = startNode;
|
|
1075
|
+
let currentData: string | number | boolean | object | null = null;
|
|
1076
|
+
const visitedNodes = new Set<string>();
|
|
1077
|
+
|
|
1078
|
+
while (currentNode) {
|
|
1079
|
+
if (visitedNodes.has(currentNode.id)) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`Circular dependency detected: node ${currentNode.id} already visited`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
visitedNodes.add(currentNode.id);
|
|
1086
|
+
|
|
1087
|
+
// Announce node execution start
|
|
1088
|
+
this.log(
|
|
1089
|
+
'info',
|
|
1090
|
+
currentNode.id,
|
|
1091
|
+
String(currentNode.type || 'node'),
|
|
1092
|
+
'Executing node...',
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
// Update node status to running
|
|
1096
|
+
(currentNode.data as Record<string, unknown>).status = 'Running';
|
|
1097
|
+
if (this.onNodeUpdate)
|
|
1098
|
+
this.onNodeUpdate(currentNode.id, currentNode.data);
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
currentData = await this.executeNode(currentNode, currentData);
|
|
1102
|
+
(currentNode.data as Record<string, unknown>).status = 'Completed';
|
|
1103
|
+
(currentNode.data as Record<string, unknown>).lastRun =
|
|
1104
|
+
new Date().toISOString();
|
|
1105
|
+
if (this.onNodeUpdate)
|
|
1106
|
+
this.onNodeUpdate(currentNode.id, currentNode.data);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
(currentNode.data as Record<string, unknown>).status = 'Error';
|
|
1109
|
+
(currentNode.data as Record<string, unknown>).lastRun =
|
|
1110
|
+
new Date().toISOString();
|
|
1111
|
+
if (this.onNodeUpdate)
|
|
1112
|
+
this.onNodeUpdate(currentNode.id, currentNode.data);
|
|
1113
|
+
throw error;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Find next node
|
|
1117
|
+
const nextNodes = this.getNextNodes(currentNode.id);
|
|
1118
|
+
if (nextNodes.length > 0) {
|
|
1119
|
+
const next = nextNodes[0];
|
|
1120
|
+
this.log(
|
|
1121
|
+
'info',
|
|
1122
|
+
next.id,
|
|
1123
|
+
String(next.type || 'node'),
|
|
1124
|
+
'Moving to next node',
|
|
1125
|
+
);
|
|
1126
|
+
currentNode = next;
|
|
1127
|
+
} else {
|
|
1128
|
+
this.log('info', 'workflow', 'engine', 'No next node. Ending.');
|
|
1129
|
+
currentNode = null;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
this.log(
|
|
1134
|
+
'success',
|
|
1135
|
+
'workflow',
|
|
1136
|
+
'engine',
|
|
1137
|
+
'Workflow execution completed successfully',
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
success: true,
|
|
1142
|
+
context: this.context,
|
|
1143
|
+
finalOutput: currentData,
|
|
1144
|
+
};
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
this.log(
|
|
1147
|
+
'error',
|
|
1148
|
+
'workflow',
|
|
1149
|
+
'engine',
|
|
1150
|
+
`Workflow execution failed: ${error}`,
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
success: false,
|
|
1155
|
+
context: this.context,
|
|
1156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
public getLogs(): AutomationLog[] {
|
|
1162
|
+
return this.context.logs;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
public getContext(): AutomationContext {
|
|
1166
|
+
return this.context;
|
|
1167
|
+
}
|
|
1168
|
+
}
|