@open-mercato/core 0.4.2-canary-51881f6bf3 → 0.4.2-canary-5f415b8a44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/generated/entities/workflow_event_trigger/index.js +33 -0
  2. package/dist/generated/entities/workflow_event_trigger/index.js.map +7 -0
  3. package/dist/generated/entities.ids.generated.js +59 -58
  4. package/dist/generated/entities.ids.generated.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/auth/events.js +30 -0
  8. package/dist/modules/auth/events.js.map +7 -0
  9. package/dist/modules/business_rules/api/execute/[ruleId]/route.js +145 -0
  10. package/dist/modules/business_rules/api/execute/[ruleId]/route.js.map +7 -0
  11. package/dist/modules/business_rules/data/validators.js +34 -0
  12. package/dist/modules/business_rules/data/validators.js.map +2 -2
  13. package/dist/modules/business_rules/index.js +21 -1
  14. package/dist/modules/business_rules/index.js.map +2 -2
  15. package/dist/modules/business_rules/lib/rule-engine.js +182 -1
  16. package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
  17. package/dist/modules/catalog/events.js +34 -0
  18. package/dist/modules/catalog/events.js.map +7 -0
  19. package/dist/modules/customers/events.js +49 -0
  20. package/dist/modules/customers/events.js.map +7 -0
  21. package/dist/modules/directory/events.js +23 -0
  22. package/dist/modules/directory/events.js.map +7 -0
  23. package/dist/modules/sales/acl.js +1 -0
  24. package/dist/modules/sales/acl.js.map +2 -2
  25. package/dist/modules/sales/backend/sales/documents/[id]/page.js +12 -0
  26. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  27. package/dist/modules/sales/commands/documents.js +62 -0
  28. package/dist/modules/sales/commands/documents.js.map +2 -2
  29. package/dist/modules/sales/events.js +63 -0
  30. package/dist/modules/sales/events.js.map +7 -0
  31. package/dist/modules/sales/lib/dictionaries.js +3 -0
  32. package/dist/modules/sales/lib/dictionaries.js.map +2 -2
  33. package/dist/modules/sales/lib/frontend/documentDataEvents.js +25 -0
  34. package/dist/modules/sales/lib/frontend/documentDataEvents.js.map +7 -0
  35. package/dist/modules/workflows/acl.js +2 -0
  36. package/dist/modules/workflows/acl.js.map +2 -2
  37. package/dist/modules/workflows/api/instances/route.js +18 -6
  38. package/dist/modules/workflows/api/instances/route.js.map +2 -2
  39. package/dist/modules/workflows/api/tasks/route.js +6 -1
  40. package/dist/modules/workflows/api/tasks/route.js.map +2 -2
  41. package/dist/modules/workflows/backend/definitions/[id]/page.js +9 -1
  42. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  43. package/dist/modules/workflows/backend/definitions/[id]/page.meta.js +1 -1
  44. package/dist/modules/workflows/backend/definitions/[id]/page.meta.js.map +2 -2
  45. package/dist/modules/workflows/backend/definitions/create/page.js +24 -15
  46. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  47. package/dist/modules/workflows/backend/definitions/create/page.meta.js +1 -1
  48. package/dist/modules/workflows/backend/definitions/create/page.meta.js.map +2 -2
  49. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +150 -132
  50. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  51. package/dist/modules/workflows/backend/definitions/visual-editor/page.meta.js +1 -1
  52. package/dist/modules/workflows/backend/definitions/visual-editor/page.meta.js.map +2 -2
  53. package/dist/modules/workflows/backend/events/[id]/page.js +1 -1
  54. package/dist/modules/workflows/backend/events/[id]/page.js.map +2 -2
  55. package/dist/modules/workflows/backend/events/[id]/page.meta.js +2 -2
  56. package/dist/modules/workflows/backend/events/[id]/page.meta.js.map +2 -2
  57. package/dist/modules/workflows/backend/instances/[id]/page.meta.js +2 -2
  58. package/dist/modules/workflows/backend/instances/[id]/page.meta.js.map +2 -2
  59. package/dist/modules/workflows/backend/tasks/[id]/page.js +1 -1
  60. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  61. package/dist/modules/workflows/backend/tasks/[id]/page.meta.js +2 -2
  62. package/dist/modules/workflows/backend/tasks/[id]/page.meta.js.map +2 -2
  63. package/dist/modules/workflows/backend/tasks/page.js +5 -6
  64. package/dist/modules/workflows/backend/tasks/page.js.map +2 -2
  65. package/dist/modules/workflows/cli.js +81 -3
  66. package/dist/modules/workflows/cli.js.map +3 -3
  67. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +481 -0
  68. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +7 -0
  69. package/dist/modules/workflows/components/EventTriggersEditor.js +553 -0
  70. package/dist/modules/workflows/components/EventTriggersEditor.js.map +7 -0
  71. package/dist/modules/workflows/data/entities.js +64 -1
  72. package/dist/modules/workflows/data/entities.js.map +2 -2
  73. package/dist/modules/workflows/data/validators.js +115 -0
  74. package/dist/modules/workflows/data/validators.js.map +2 -2
  75. package/dist/modules/workflows/events.js +38 -0
  76. package/dist/modules/workflows/events.js.map +7 -0
  77. package/dist/modules/workflows/examples/checkout-demo-definition.json +1 -5
  78. package/dist/modules/workflows/examples/order-approval-definition.json +257 -0
  79. package/dist/modules/workflows/examples/order-approval-guard-rules.json +32 -0
  80. package/dist/modules/workflows/lib/activity-executor.js +75 -13
  81. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  82. package/dist/modules/workflows/lib/event-trigger-service.js +308 -0
  83. package/dist/modules/workflows/lib/event-trigger-service.js.map +7 -0
  84. package/dist/modules/workflows/lib/graph-utils.js +71 -2
  85. package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
  86. package/dist/modules/workflows/lib/seeds.js +22 -5
  87. package/dist/modules/workflows/lib/seeds.js.map +2 -2
  88. package/dist/modules/workflows/lib/start-validator.js +33 -23
  89. package/dist/modules/workflows/lib/start-validator.js.map +2 -2
  90. package/dist/modules/workflows/lib/transition-handler.js +157 -45
  91. package/dist/modules/workflows/lib/transition-handler.js.map +3 -3
  92. package/dist/modules/workflows/migrations/Migration20260123143500.js +36 -0
  93. package/dist/modules/workflows/migrations/Migration20260123143500.js.map +7 -0
  94. package/dist/modules/workflows/subscribers/event-trigger.js +78 -0
  95. package/dist/modules/workflows/subscribers/event-trigger.js.map +7 -0
  96. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +323 -0
  97. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +7 -0
  98. package/dist/modules/workflows/widgets/injection/order-approval/widget.js +17 -0
  99. package/dist/modules/workflows/widgets/injection/order-approval/widget.js.map +7 -0
  100. package/dist/modules/workflows/widgets/injection-table.js +19 -0
  101. package/dist/modules/workflows/widgets/injection-table.js.map +7 -0
  102. package/generated/entities/workflow_event_trigger/index.ts +15 -0
  103. package/generated/entities.ids.generated.ts +59 -58
  104. package/generated/entity-fields-registry.ts +2 -0
  105. package/package.json +3 -5
  106. package/src/modules/auth/events.ts +39 -0
  107. package/src/modules/business_rules/api/execute/[ruleId]/route.ts +163 -0
  108. package/src/modules/business_rules/data/validators.ts +40 -0
  109. package/src/modules/business_rules/index.ts +25 -0
  110. package/src/modules/business_rules/lib/rule-engine.ts +281 -1
  111. package/src/modules/catalog/events.ts +45 -0
  112. package/src/modules/customers/events.ts +63 -0
  113. package/src/modules/directory/events.ts +31 -0
  114. package/src/modules/sales/acl.ts +1 -0
  115. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +16 -0
  116. package/src/modules/sales/commands/documents.ts +74 -1
  117. package/src/modules/sales/events.ts +82 -0
  118. package/src/modules/sales/lib/dictionaries.ts +3 -0
  119. package/src/modules/sales/lib/frontend/documentDataEvents.ts +28 -0
  120. package/src/modules/workflows/acl.ts +2 -0
  121. package/src/modules/workflows/api/instances/route.ts +21 -7
  122. package/src/modules/workflows/api/tasks/route.ts +7 -1
  123. package/src/modules/workflows/backend/definitions/[id]/page.meta.ts +1 -1
  124. package/src/modules/workflows/backend/definitions/[id]/page.tsx +9 -0
  125. package/src/modules/workflows/backend/definitions/create/page.meta.ts +1 -1
  126. package/src/modules/workflows/backend/definitions/create/page.tsx +9 -0
  127. package/src/modules/workflows/backend/definitions/visual-editor/page.meta.ts +1 -1
  128. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +21 -3
  129. package/src/modules/workflows/backend/events/[id]/page.meta.ts +2 -2
  130. package/src/modules/workflows/backend/events/[id]/page.tsx +1 -1
  131. package/src/modules/workflows/backend/instances/[id]/page.meta.ts +2 -2
  132. package/src/modules/workflows/backend/tasks/[id]/page.meta.ts +2 -2
  133. package/src/modules/workflows/backend/tasks/[id]/page.tsx +1 -1
  134. package/src/modules/workflows/backend/tasks/page.tsx +5 -6
  135. package/src/modules/workflows/cli.ts +111 -0
  136. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +581 -0
  137. package/src/modules/workflows/components/EventTriggersEditor.tsx +664 -0
  138. package/src/modules/workflows/data/entities.ts +124 -0
  139. package/src/modules/workflows/data/validators.ts +138 -0
  140. package/src/modules/workflows/events.ts +49 -0
  141. package/src/modules/workflows/examples/checkout-demo-definition.json +1 -5
  142. package/src/modules/workflows/examples/order-approval-definition.json +257 -0
  143. package/src/modules/workflows/examples/order-approval-guard-rules.json +32 -0
  144. package/src/modules/workflows/i18n/en.json +71 -0
  145. package/src/modules/workflows/lib/activity-executor.ts +129 -16
  146. package/src/modules/workflows/lib/event-trigger-service.ts +557 -0
  147. package/src/modules/workflows/lib/graph-utils.ts +117 -2
  148. package/src/modules/workflows/lib/seeds.ts +34 -8
  149. package/src/modules/workflows/lib/start-validator.ts +38 -28
  150. package/src/modules/workflows/lib/transition-handler.ts +208 -55
  151. package/src/modules/workflows/migrations/Migration20260123143500.ts +38 -0
  152. package/src/modules/workflows/subscribers/event-trigger.ts +109 -0
  153. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +446 -0
  154. package/src/modules/workflows/widgets/injection/order-approval/widget.ts +16 -0
  155. package/src/modules/workflows/widgets/injection-table.ts +21 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Workflows Module - Event Trigger Service
3
+ *
4
+ * Core service for evaluating and processing workflow event triggers.
5
+ * Handles pattern matching, filter evaluation, context mapping, and workflow starting.
6
+ */
7
+
8
+ import type { EntityManager } from '@mikro-orm/core'
9
+ import type { AwilixContainer } from 'awilix'
10
+ import type { CacheService } from '@open-mercato/cache'
11
+ import {
12
+ WorkflowEventTrigger,
13
+ WorkflowDefinition,
14
+ WorkflowInstance,
15
+ type TriggerFilterCondition,
16
+ type TriggerContextMapping,
17
+ type WorkflowEventTriggerConfig,
18
+ type WorkflowDefinitionTrigger,
19
+ } from '../data/entities'
20
+ import { startWorkflow, executeWorkflow } from './workflow-executor'
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ export interface EventTriggerContext {
27
+ eventName: string
28
+ payload: Record<string, unknown>
29
+ tenantId: string
30
+ organizationId: string
31
+ }
32
+
33
+ export interface ProcessTriggersResult {
34
+ triggered: number
35
+ skipped: number
36
+ errors: Array<{ triggerId: string; error: string }>
37
+ instances: Array<{ triggerId: string; instanceId: string }>
38
+ }
39
+
40
+ /**
41
+ * Unified trigger interface for both legacy (entity) and embedded (definition) triggers
42
+ */
43
+ export interface UnifiedTrigger {
44
+ id: string // For legacy: entity ID, for embedded: `${definitionId}:${triggerId}`
45
+ triggerId: string
46
+ name: string
47
+ description?: string | null
48
+ eventPattern: string
49
+ config: WorkflowEventTriggerConfig | null
50
+ enabled: boolean
51
+ priority: number
52
+ workflowDefinitionId: string
53
+ workflowId: string
54
+ workflowVersion: number
55
+ source: 'legacy' | 'embedded'
56
+ tenantId: string
57
+ organizationId: string
58
+ }
59
+
60
+ interface CachedTriggers {
61
+ triggers: UnifiedTrigger[]
62
+ cachedAt: number
63
+ }
64
+
65
+ // Cache TTL: 5 minutes
66
+ const TRIGGER_CACHE_TTL = 5 * 60 * 1000
67
+
68
+ // ============================================================================
69
+ // Pattern Matching
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Match an event name against a pattern.
74
+ *
75
+ * Supports:
76
+ * - Exact match: `customers.people.created`
77
+ * - Wildcard `*` matches single segment: `customers.*` matches `customers.people` but not `customers.people.created`
78
+ * - Global wildcard: `*` alone matches all events
79
+ */
80
+ export function matchEventPattern(eventName: string, pattern: string): boolean {
81
+ // Global wildcard matches all events
82
+ if (pattern === '*') return true
83
+
84
+ // Exact match
85
+ if (pattern === eventName) return true
86
+
87
+ // No wildcards in pattern means we need exact match, which already failed
88
+ if (!pattern.includes('*')) return false
89
+
90
+ // Convert pattern to regex:
91
+ // - Escape regex special chars (except *)
92
+ // - Replace * with [^.]+ (match one or more non-dot chars)
93
+ const regexPattern = pattern
94
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
95
+ .replace(/\*/g, '[^.]+')
96
+ const regex = new RegExp(`^${regexPattern}$`)
97
+ return regex.test(eventName)
98
+ }
99
+
100
+ // ============================================================================
101
+ // Filter Evaluation
102
+ // ============================================================================
103
+
104
+ /**
105
+ * Get a nested value from an object using dot notation.
106
+ */
107
+ function getNestedValue(obj: unknown, path: string): unknown {
108
+ if (obj === null || obj === undefined) return undefined
109
+
110
+ const parts = path.split('.')
111
+ let current: unknown = obj
112
+
113
+ for (const part of parts) {
114
+ if (current === null || current === undefined) return undefined
115
+ if (typeof current !== 'object') return undefined
116
+ current = (current as Record<string, unknown>)[part]
117
+ }
118
+
119
+ return current
120
+ }
121
+
122
+ /**
123
+ * Evaluate a single filter condition against the event payload.
124
+ */
125
+ function evaluateCondition(condition: TriggerFilterCondition, payload: Record<string, unknown>): boolean {
126
+ const value = getNestedValue(payload, condition.field)
127
+ const expected = condition.value
128
+
129
+ switch (condition.operator) {
130
+ case 'eq':
131
+ return value === expected
132
+
133
+ case 'neq':
134
+ return value !== expected
135
+
136
+ case 'gt':
137
+ return typeof value === 'number' && typeof expected === 'number' && value > expected
138
+
139
+ case 'gte':
140
+ return typeof value === 'number' && typeof expected === 'number' && value >= expected
141
+
142
+ case 'lt':
143
+ return typeof value === 'number' && typeof expected === 'number' && value < expected
144
+
145
+ case 'lte':
146
+ return typeof value === 'number' && typeof expected === 'number' && value <= expected
147
+
148
+ case 'contains':
149
+ if (typeof value === 'string' && typeof expected === 'string') {
150
+ return value.includes(expected)
151
+ }
152
+ if (Array.isArray(value)) {
153
+ return value.includes(expected)
154
+ }
155
+ return false
156
+
157
+ case 'startsWith':
158
+ return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected)
159
+
160
+ case 'endsWith':
161
+ return typeof value === 'string' && typeof expected === 'string' && value.endsWith(expected)
162
+
163
+ case 'in':
164
+ return Array.isArray(expected) && expected.includes(value)
165
+
166
+ case 'notIn':
167
+ return Array.isArray(expected) && !expected.includes(value)
168
+
169
+ case 'exists':
170
+ return value !== undefined && value !== null
171
+
172
+ case 'notExists':
173
+ return value === undefined || value === null
174
+
175
+ case 'regex':
176
+ if (typeof value !== 'string' || typeof expected !== 'string') return false
177
+ try {
178
+ const regex = new RegExp(expected)
179
+ return regex.test(value)
180
+ } catch {
181
+ return false
182
+ }
183
+
184
+ default:
185
+ return false
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Evaluate all filter conditions against the event payload.
191
+ * All conditions must pass (AND logic).
192
+ */
193
+ export function evaluateFilterConditions(
194
+ conditions: TriggerFilterCondition[] | undefined,
195
+ payload: Record<string, unknown>
196
+ ): boolean {
197
+ if (!conditions || conditions.length === 0) return true
198
+
199
+ return conditions.every(condition => evaluateCondition(condition, payload))
200
+ }
201
+
202
+ // ============================================================================
203
+ // Context Mapping
204
+ // ============================================================================
205
+
206
+ /**
207
+ * Map event payload to workflow initial context.
208
+ */
209
+ export function mapEventToContext(
210
+ mapping: TriggerContextMapping[] | undefined,
211
+ payload: Record<string, unknown>
212
+ ): Record<string, unknown> {
213
+ const context: Record<string, unknown> = {}
214
+
215
+ if (!mapping || mapping.length === 0) return context
216
+
217
+ for (const item of mapping) {
218
+ const value = getNestedValue(payload, item.sourceExpression)
219
+ context[item.targetKey] = value !== undefined ? value : item.defaultValue
220
+ }
221
+
222
+ return context
223
+ }
224
+
225
+ // ============================================================================
226
+ // Trigger Loading with Caching
227
+ // ============================================================================
228
+
229
+ // In-memory cache for triggers (per tenant/org)
230
+ const triggerCache = new Map<string, CachedTriggers>()
231
+
232
+ function getCacheKey(tenantId: string, organizationId: string): string {
233
+ return `${tenantId}:${organizationId}`
234
+ }
235
+
236
+ /**
237
+ * Load legacy triggers from WorkflowEventTrigger entity table.
238
+ * For backward compatibility with existing triggers.
239
+ */
240
+ async function loadLegacyTriggers(
241
+ em: EntityManager,
242
+ tenantId: string,
243
+ organizationId: string
244
+ ): Promise<UnifiedTrigger[]> {
245
+ const legacyTriggers = await em.find(
246
+ WorkflowEventTrigger,
247
+ {
248
+ tenantId,
249
+ organizationId,
250
+ enabled: true,
251
+ deletedAt: null,
252
+ },
253
+ {
254
+ orderBy: { priority: 'DESC', createdAt: 'ASC' },
255
+ }
256
+ )
257
+
258
+ // Get definitions for these triggers to get workflowId
259
+ const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]
260
+ const definitions = definitionIds.length > 0 ? await em.find(WorkflowDefinition, {
261
+ id: { $in: definitionIds },
262
+ enabled: true,
263
+ deletedAt: null,
264
+ }) : []
265
+ const definitionMap = new Map(definitions.map(d => [d.id, d]))
266
+
267
+ return legacyTriggers
268
+ .filter(t => definitionMap.has(t.workflowDefinitionId))
269
+ .map(t => {
270
+ const def = definitionMap.get(t.workflowDefinitionId)!
271
+ return {
272
+ id: t.id,
273
+ triggerId: t.id,
274
+ name: t.name,
275
+ description: t.description,
276
+ eventPattern: t.eventPattern,
277
+ config: t.config ?? null,
278
+ enabled: t.enabled,
279
+ priority: t.priority,
280
+ workflowDefinitionId: t.workflowDefinitionId,
281
+ workflowId: def.workflowId,
282
+ workflowVersion: def.version,
283
+ source: 'legacy' as const,
284
+ tenantId: t.tenantId,
285
+ organizationId: t.organizationId,
286
+ }
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Load embedded triggers from workflow definitions.
292
+ * New triggers are embedded directly in the definition JSONB.
293
+ */
294
+ async function loadEmbeddedTriggers(
295
+ em: EntityManager,
296
+ tenantId: string,
297
+ organizationId: string
298
+ ): Promise<UnifiedTrigger[]> {
299
+ // Load all enabled definitions that may have triggers
300
+ const definitions = await em.find(
301
+ WorkflowDefinition,
302
+ {
303
+ tenantId,
304
+ organizationId,
305
+ enabled: true,
306
+ deletedAt: null,
307
+ }
308
+ )
309
+
310
+ const triggers: UnifiedTrigger[] = []
311
+
312
+ for (const def of definitions) {
313
+ const embeddedTriggers = def.definition?.triggers as WorkflowDefinitionTrigger[] | undefined
314
+ if (!embeddedTriggers || embeddedTriggers.length === 0) continue
315
+
316
+ for (const trigger of embeddedTriggers) {
317
+ if (!trigger.enabled) continue
318
+
319
+ triggers.push({
320
+ id: `${def.id}:${trigger.triggerId}`,
321
+ triggerId: trigger.triggerId,
322
+ name: trigger.name,
323
+ description: trigger.description ?? null,
324
+ eventPattern: trigger.eventPattern,
325
+ config: trigger.config ?? null,
326
+ enabled: trigger.enabled,
327
+ priority: trigger.priority,
328
+ workflowDefinitionId: def.id,
329
+ workflowId: def.workflowId,
330
+ workflowVersion: def.version,
331
+ source: 'embedded' as const,
332
+ tenantId,
333
+ organizationId,
334
+ })
335
+ }
336
+ }
337
+
338
+ return triggers
339
+ }
340
+
341
+ /**
342
+ * Load all enabled triggers for a tenant/organization with caching.
343
+ * Merges both legacy (entity) triggers and embedded (definition) triggers.
344
+ */
345
+ export async function loadTriggersForTenant(
346
+ em: EntityManager,
347
+ tenantId: string,
348
+ organizationId: string,
349
+ cacheService?: CacheService
350
+ ): Promise<UnifiedTrigger[]> {
351
+ const cacheKey = getCacheKey(tenantId, organizationId)
352
+
353
+ // Check in-memory cache
354
+ const cached = triggerCache.get(cacheKey)
355
+ if (cached && Date.now() - cached.cachedAt < TRIGGER_CACHE_TTL) {
356
+ return cached.triggers
357
+ }
358
+
359
+ // Load from both sources
360
+ const [legacyTriggers, embeddedTriggers] = await Promise.all([
361
+ loadLegacyTriggers(em, tenantId, organizationId),
362
+ loadEmbeddedTriggers(em, tenantId, organizationId),
363
+ ])
364
+
365
+ // Merge and sort by priority (higher first)
366
+ const allTriggers = [...legacyTriggers, ...embeddedTriggers]
367
+ .sort((a, b) => b.priority - a.priority)
368
+
369
+ // Update cache
370
+ triggerCache.set(cacheKey, {
371
+ triggers: allTriggers,
372
+ cachedAt: Date.now(),
373
+ })
374
+
375
+ return allTriggers
376
+ }
377
+
378
+ /**
379
+ * Invalidate trigger cache for a tenant/organization.
380
+ * Call this when:
381
+ * - Legacy triggers are created/updated/deleted
382
+ * - Workflow definitions with embedded triggers are created/updated/deleted
383
+ */
384
+ export function invalidateTriggerCache(tenantId: string, organizationId?: string): void {
385
+ if (organizationId) {
386
+ // Invalidate specific org
387
+ const cacheKey = getCacheKey(tenantId, organizationId)
388
+ triggerCache.delete(cacheKey)
389
+ } else {
390
+ // Invalidate all orgs for tenant
391
+ for (const key of triggerCache.keys()) {
392
+ if (key.startsWith(`${tenantId}:`)) {
393
+ triggerCache.delete(key)
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ // ============================================================================
400
+ // Trigger Matching
401
+ // ============================================================================
402
+
403
+ /**
404
+ * Find all triggers that match an event.
405
+ */
406
+ export async function findMatchingTriggers(
407
+ em: EntityManager,
408
+ context: EventTriggerContext
409
+ ): Promise<UnifiedTrigger[]> {
410
+ const triggers = await loadTriggersForTenant(
411
+ em,
412
+ context.tenantId,
413
+ context.organizationId
414
+ )
415
+
416
+ return triggers.filter(trigger => {
417
+ // Check event pattern
418
+ if (!matchEventPattern(context.eventName, trigger.eventPattern)) {
419
+ return false
420
+ }
421
+
422
+ // Check filter conditions
423
+ if (!evaluateFilterConditions(trigger.config?.filterConditions, context.payload)) {
424
+ return false
425
+ }
426
+
427
+ return true
428
+ })
429
+ }
430
+
431
+ // ============================================================================
432
+ // Trigger Processing
433
+ // ============================================================================
434
+
435
+ /**
436
+ * Check if max concurrent instances limit is reached.
437
+ */
438
+ async function checkConcurrencyLimit(
439
+ em: EntityManager,
440
+ trigger: UnifiedTrigger
441
+ ): Promise<boolean> {
442
+ const maxInstances = trigger.config?.maxConcurrentInstances
443
+
444
+ if (!maxInstances) return true // No limit
445
+
446
+ // Count running instances for this trigger's workflow definition
447
+ const runningCount = await em.count(WorkflowInstance, {
448
+ definitionId: trigger.workflowDefinitionId,
449
+ status: { $in: ['RUNNING', 'WAITING_FOR_ACTIVITIES'] },
450
+ tenantId: trigger.tenantId,
451
+ organizationId: trigger.organizationId,
452
+ deletedAt: null,
453
+ })
454
+
455
+ return runningCount < maxInstances
456
+ }
457
+
458
+ /**
459
+ * Process all matching triggers for an event and start workflows.
460
+ */
461
+ export async function processEventTriggers(
462
+ em: EntityManager,
463
+ container: AwilixContainer,
464
+ context: EventTriggerContext
465
+ ): Promise<ProcessTriggersResult> {
466
+ const result: ProcessTriggersResult = {
467
+ triggered: 0,
468
+ skipped: 0,
469
+ errors: [],
470
+ instances: [],
471
+ }
472
+
473
+ // Find matching triggers
474
+ const triggers = await findMatchingTriggers(em, context)
475
+
476
+ if (triggers.length === 0) {
477
+ return result
478
+ }
479
+
480
+ // Process each trigger (definitions already validated during loading)
481
+ for (const trigger of triggers) {
482
+ try {
483
+ // Check concurrency limit
484
+ const canStart = await checkConcurrencyLimit(em, trigger)
485
+ if (!canStart) {
486
+ console.log(`[workflow-trigger] Skipping trigger "${trigger.name}": max concurrent instances reached`)
487
+ result.skipped++
488
+ continue
489
+ }
490
+
491
+ // Map event payload to workflow context
492
+ const mappedContext = mapEventToContext(trigger.config?.contextMapping, context.payload)
493
+
494
+ // Extract entity info from payload for metadata
495
+ const payloadId = context.payload?.id as string | undefined
496
+ const payloadEntityType = context.payload?.entityType as string | undefined
497
+
498
+ // Include event metadata and payload in context
499
+ const initialContext = {
500
+ // Include raw event payload fields (e.g., id, organizationId, tenantId)
501
+ ...context.payload,
502
+ // Override with explicit mappings if provided
503
+ ...mappedContext,
504
+ __trigger: {
505
+ triggerId: trigger.id,
506
+ triggerName: trigger.name,
507
+ eventName: context.eventName,
508
+ eventPayload: context.payload,
509
+ triggeredAt: new Date().toISOString(),
510
+ source: trigger.source,
511
+ },
512
+ }
513
+
514
+ // Start workflow
515
+ const instance = await startWorkflow(em, {
516
+ workflowId: trigger.workflowId,
517
+ version: trigger.workflowVersion,
518
+ initialContext,
519
+ metadata: {
520
+ initiatedBy: `trigger:${trigger.id}`,
521
+ // Include entityId and entityType for widget discovery
522
+ entityId: payloadId,
523
+ entityType: payloadEntityType || trigger.config?.entityType,
524
+ labels: {
525
+ trigger_id: trigger.id,
526
+ trigger_name: trigger.name,
527
+ event_name: context.eventName,
528
+ trigger_source: trigger.source,
529
+ },
530
+ },
531
+ tenantId: context.tenantId,
532
+ organizationId: context.organizationId,
533
+ })
534
+
535
+ result.triggered++
536
+ result.instances.push({
537
+ triggerId: trigger.id,
538
+ instanceId: instance.id,
539
+ })
540
+
541
+ // Execute workflow asynchronously (don't wait)
542
+ executeWorkflow(em.fork(), container, instance.id).catch(err => {
543
+ console.error(`[workflow-trigger] Error executing workflow ${instance.id}:`, err)
544
+ })
545
+
546
+ } catch (error) {
547
+ const errorMessage = error instanceof Error ? error.message : String(error)
548
+ console.error(`[workflow-trigger] Error processing trigger "${trigger.name}":`, error)
549
+ result.errors.push({
550
+ triggerId: trigger.id,
551
+ error: errorMessage,
552
+ })
553
+ }
554
+ }
555
+
556
+ return result
557
+ }
@@ -205,12 +205,20 @@ export function definitionToGraph(
205
205
  definition: WorkflowDefinition['definition'],
206
206
  options: DefinitionToGraphOptions = {}
207
207
  ): { nodes: Node[]; edges: Edge[] } {
208
- const { autoLayout = true, layoutSpacing = { vertical: 250, horizontal: 100 } } = options
208
+ const { autoLayout = true, layoutSpacing = { vertical: 200, horizontal: 300 } } = options
209
+
210
+ // Build step map for quick lookup
211
+ const stepMap = new Map(definition.steps.map(step => [step.stepId, step]))
212
+
213
+ // Calculate smart layout positions if autoLayout is enabled
214
+ const positions = autoLayout
215
+ ? calculateSmartLayout(definition.steps, definition.transitions, layoutSpacing)
216
+ : null
209
217
 
210
218
  // Convert steps to nodes
211
219
  const nodes: Node[] = definition.steps.map((step, index) => {
212
220
  // Determine position
213
- let position = { x: 250, y: 50 + index * layoutSpacing.vertical }
221
+ let position = positions?.get(step.stepId) || { x: 250, y: 50 + index * layoutSpacing.vertical }
214
222
 
215
223
  // Use stored position if available and not auto-layouting
216
224
  if (!autoLayout && (step as any)._editorPosition) {
@@ -326,6 +334,113 @@ export function definitionToGraph(
326
334
  return { nodes, edges }
327
335
  }
328
336
 
337
+ /**
338
+ * Calculate smart layout positions for workflow nodes
339
+ * Uses a layered/hierarchical layout algorithm that:
340
+ * 1. Assigns levels (ranks) to nodes based on graph topology
341
+ * 2. Spreads sibling nodes horizontally at the same level
342
+ * 3. Centers merge points below their incoming nodes
343
+ */
344
+ function calculateSmartLayout(
345
+ steps: any[],
346
+ transitions: any[],
347
+ spacing: { vertical: number; horizontal: number }
348
+ ): Map<string, { x: number; y: number }> {
349
+ const positions = new Map<string, { x: number; y: number }>()
350
+
351
+ if (steps.length === 0) return positions
352
+
353
+ // Build adjacency lists
354
+ const outgoing = new Map<string, string[]>() // node -> children
355
+ const incoming = new Map<string, string[]>() // node -> parents
356
+
357
+ for (const step of steps) {
358
+ outgoing.set(step.stepId, [])
359
+ incoming.set(step.stepId, [])
360
+ }
361
+
362
+ for (const t of transitions) {
363
+ const children = outgoing.get(t.fromStepId) || []
364
+ children.push(t.toStepId)
365
+ outgoing.set(t.fromStepId, children)
366
+
367
+ const parents = incoming.get(t.toStepId) || []
368
+ parents.push(t.fromStepId)
369
+ incoming.set(t.toStepId, parents)
370
+ }
371
+
372
+ // Find start node(s) - nodes with no incoming edges
373
+ const startNodes = steps.filter(s => (incoming.get(s.stepId) || []).length === 0)
374
+ if (startNodes.length === 0) {
375
+ // Fallback: use first step as start
376
+ startNodes.push(steps[0])
377
+ }
378
+
379
+ // Assign levels using BFS (longest path from start)
380
+ const levels = new Map<string, number>()
381
+ const queue: Array<{ id: string; level: number }> = []
382
+
383
+ for (const start of startNodes) {
384
+ queue.push({ id: start.stepId, level: 0 })
385
+ }
386
+
387
+ while (queue.length > 0) {
388
+ const { id, level } = queue.shift()!
389
+ const currentLevel = levels.get(id)
390
+
391
+ // Take the maximum level (longest path)
392
+ if (currentLevel === undefined || level > currentLevel) {
393
+ levels.set(id, level)
394
+ }
395
+
396
+ const children = outgoing.get(id) || []
397
+ for (const child of children) {
398
+ queue.push({ id: child, level: level + 1 })
399
+ }
400
+ }
401
+
402
+ // Group nodes by level
403
+ const nodesByLevel = new Map<number, string[]>()
404
+ for (const [nodeId, level] of levels) {
405
+ const nodesAtLevel = nodesByLevel.get(level) || []
406
+ nodesAtLevel.push(nodeId)
407
+ nodesByLevel.set(level, nodesAtLevel)
408
+ }
409
+
410
+ // Calculate positions
411
+ const centerX = 400 // Center line for the graph
412
+ const startY = 50
413
+
414
+ for (const [level, nodeIds] of nodesByLevel) {
415
+ const count = nodeIds.length
416
+ const y = startY + level * spacing.vertical
417
+
418
+ if (count === 1) {
419
+ // Single node at this level - center it
420
+ positions.set(nodeIds[0], { x: centerX, y })
421
+ } else {
422
+ // Multiple nodes at this level - spread them horizontally
423
+ const totalWidth = (count - 1) * spacing.horizontal
424
+ const startX = centerX - totalWidth / 2
425
+
426
+ // Sort nodes by their parent's position for consistent ordering
427
+ nodeIds.sort((a, b) => {
428
+ const parentsA = incoming.get(a) || []
429
+ const parentsB = incoming.get(b) || []
430
+ const parentPosA = parentsA.length > 0 ? (positions.get(parentsA[0])?.x || 0) : 0
431
+ const parentPosB = parentsB.length > 0 ? (positions.get(parentsB[0])?.x || 0) : 0
432
+ return parentPosA - parentPosB
433
+ })
434
+
435
+ nodeIds.forEach((nodeId, idx) => {
436
+ positions.set(nodeId, { x: startX + idx * spacing.horizontal, y })
437
+ })
438
+ }
439
+ }
440
+
441
+ return positions
442
+ }
443
+
329
444
  /**
330
445
  * Map node type to step type (for graph → definition)
331
446
  */