@valentine-efagene/qshelter-common 2.0.74 → 2.0.76
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/dist/generated/client/browser.d.ts +5 -5
- package/dist/generated/client/client.d.ts +5 -5
- package/dist/generated/client/internal/class.d.ts +11 -11
- package/dist/generated/client/internal/class.js +2 -2
- package/dist/generated/client/internal/prismaNamespace.d.ts +95 -95
- package/dist/generated/client/internal/prismaNamespace.js +25 -25
- package/dist/generated/client/internal/prismaNamespaceBrowser.d.ts +27 -27
- package/dist/generated/client/internal/prismaNamespaceBrowser.js +25 -25
- package/dist/generated/client/models/Contract.d.ts +155 -155
- package/dist/generated/client/models/index.d.ts +3 -0
- package/dist/generated/client/models/index.js +3 -0
- package/dist/generated/client/models.d.ts +1 -1
- package/dist/src/events/bus/event-bus.service.d.ts +84 -0
- package/dist/src/events/bus/event-bus.service.js +372 -0
- package/dist/src/events/bus/event-bus.types.d.ts +73 -0
- package/dist/src/events/bus/event-bus.types.js +22 -0
- package/dist/src/events/index.d.ts +5 -6
- package/dist/src/events/index.js +7 -8
- package/dist/src/events/notifications/event-publisher.d.ts +41 -0
- package/dist/src/events/notifications/event-publisher.js +111 -0
- package/dist/src/events/notifications/notification-enums.d.ts +46 -0
- package/dist/src/events/notifications/notification-enums.js +59 -0
- package/dist/src/events/notifications/notification-event.d.ts +76 -0
- package/dist/src/events/notifications/notification-event.js +1 -0
- package/dist/src/events/unified/unified-event.service.d.ts +157 -0
- package/dist/src/events/unified/unified-event.service.js +177 -0
- package/dist/src/events/workflow/event-config.service.d.ts +123 -0
- package/dist/src/events/workflow/event-config.service.js +416 -0
- package/dist/src/events/workflow/event-seeder.d.ts +80 -0
- package/dist/src/events/workflow/event-seeder.js +343 -0
- package/dist/src/events/workflow/workflow-event.service.d.ts +230 -0
- package/dist/src/events/workflow/workflow-event.service.js +682 -0
- package/dist/src/events/workflow/workflow-types.d.ts +364 -0
- package/dist/src/events/workflow/workflow-types.js +22 -0
- package/package.json +4 -1
- package/prisma/schema.prisma +87 -79
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Event Service
|
|
3
|
+
*
|
|
4
|
+
* Handles emission and processing of workflow events.
|
|
5
|
+
* Events are stored in the database and processed by registered handlers.
|
|
6
|
+
*
|
|
7
|
+
* Design principles:
|
|
8
|
+
* 1. Events are immutable once emitted
|
|
9
|
+
* 2. Handlers are configured by admins, not hardcoded
|
|
10
|
+
* 3. Each handler execution is logged for audit
|
|
11
|
+
* 4. Failed handlers can be retried
|
|
12
|
+
* 5. Events can be correlated for tracing
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const eventService = new WorkflowEventService(prisma);
|
|
17
|
+
*
|
|
18
|
+
* // Emit an event
|
|
19
|
+
* const event = await eventService.emit(tenantId, {
|
|
20
|
+
* eventType: 'DOCUMENT_UPLOADED',
|
|
21
|
+
* payload: {
|
|
22
|
+
* contractId: 'ctr_123',
|
|
23
|
+
* stepId: 'step_456',
|
|
24
|
+
* documentUrl: 'https://...',
|
|
25
|
+
* },
|
|
26
|
+
* source: 'contract-service',
|
|
27
|
+
* actor: { id: 'user_789', type: 'USER' },
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Process the event (run all handlers)
|
|
31
|
+
* const result = await eventService.processEvent(event.id);
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
import { EventPublisher } from '../notifications/event-publisher';
|
|
35
|
+
import { NotificationChannel } from '../notifications/notification-enums';
|
|
36
|
+
/**
|
|
37
|
+
* Simple in-memory automation registry
|
|
38
|
+
*/
|
|
39
|
+
class InMemoryAutomationRegistry {
|
|
40
|
+
automations = new Map();
|
|
41
|
+
get(automationName) {
|
|
42
|
+
return this.automations.get(automationName);
|
|
43
|
+
}
|
|
44
|
+
register(automationName, handler) {
|
|
45
|
+
this.automations.set(automationName, handler);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Simple in-memory service registry
|
|
50
|
+
*/
|
|
51
|
+
class InMemoryServiceRegistry {
|
|
52
|
+
services = new Map();
|
|
53
|
+
get(serviceName) {
|
|
54
|
+
return this.services.get(serviceName);
|
|
55
|
+
}
|
|
56
|
+
register(serviceName, service) {
|
|
57
|
+
this.services.set(serviceName, service);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class WorkflowEventService {
|
|
61
|
+
prisma;
|
|
62
|
+
automationRegistry;
|
|
63
|
+
eventPublisher;
|
|
64
|
+
constructor(prisma, automationRegistry, eventPublisher) {
|
|
65
|
+
this.prisma = prisma;
|
|
66
|
+
this.automationRegistry = automationRegistry || new InMemoryAutomationRegistry();
|
|
67
|
+
this.eventPublisher = eventPublisher || new EventPublisher('workflow-event-service');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Register an automation for RUN_AUTOMATION handlers
|
|
71
|
+
*
|
|
72
|
+
* Automations are business logic functions that can be triggered by events.
|
|
73
|
+
* Example: "calculateLateFee", "sendWelcomePackage", "archiveContract"
|
|
74
|
+
*/
|
|
75
|
+
registerAutomation(name, handler) {
|
|
76
|
+
this.automationRegistry.register(name, handler);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Emit an event
|
|
80
|
+
*
|
|
81
|
+
* This creates an event record and optionally processes it immediately
|
|
82
|
+
* or leaves it for async processing by a worker.
|
|
83
|
+
*
|
|
84
|
+
* @param tenantId - Tenant context
|
|
85
|
+
* @param input - Event details
|
|
86
|
+
* @param processImmediately - Whether to process handlers now (default: false)
|
|
87
|
+
*/
|
|
88
|
+
async emit(tenantId, input, processImmediately = false) {
|
|
89
|
+
// Look up the event type by code
|
|
90
|
+
const eventType = await this.prisma.eventType.findFirst({
|
|
91
|
+
where: {
|
|
92
|
+
tenantId,
|
|
93
|
+
code: input.eventType.toUpperCase(),
|
|
94
|
+
enabled: true,
|
|
95
|
+
channel: { enabled: true },
|
|
96
|
+
},
|
|
97
|
+
include: {
|
|
98
|
+
channel: true,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
if (!eventType) {
|
|
102
|
+
throw new Error(`Event type '${input.eventType}' not found or not enabled for tenant`);
|
|
103
|
+
}
|
|
104
|
+
// TODO: Validate payload against schema if defined
|
|
105
|
+
// if (eventType.payloadSchema) {
|
|
106
|
+
// validateJsonSchema(input.payload, eventType.payloadSchema);
|
|
107
|
+
// }
|
|
108
|
+
// Create the event record
|
|
109
|
+
const event = await this.prisma.workflowEvent.create({
|
|
110
|
+
data: {
|
|
111
|
+
tenantId,
|
|
112
|
+
eventTypeId: eventType.id,
|
|
113
|
+
payload: input.payload,
|
|
114
|
+
source: input.source,
|
|
115
|
+
actorId: input.actor?.id,
|
|
116
|
+
actorType: input.actor?.type || 'SYSTEM',
|
|
117
|
+
correlationId: input.correlationId,
|
|
118
|
+
causationId: input.causationId,
|
|
119
|
+
status: 'PENDING',
|
|
120
|
+
},
|
|
121
|
+
include: {
|
|
122
|
+
eventType: {
|
|
123
|
+
include: { channel: true },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const result = {
|
|
128
|
+
id: event.id,
|
|
129
|
+
tenantId: event.tenantId,
|
|
130
|
+
eventTypeId: event.eventTypeId,
|
|
131
|
+
eventTypeCode: event.eventType.code,
|
|
132
|
+
channelCode: event.eventType.channel.code,
|
|
133
|
+
payload: event.payload,
|
|
134
|
+
source: event.source,
|
|
135
|
+
actorId: event.actorId,
|
|
136
|
+
actorType: event.actorType,
|
|
137
|
+
status: event.status,
|
|
138
|
+
correlationId: event.correlationId,
|
|
139
|
+
causationId: event.causationId,
|
|
140
|
+
error: event.error,
|
|
141
|
+
processedAt: event.processedAt,
|
|
142
|
+
createdAt: event.createdAt,
|
|
143
|
+
};
|
|
144
|
+
// Process immediately if requested
|
|
145
|
+
if (processImmediately) {
|
|
146
|
+
await this.processEvent(event.id);
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Process an event by executing all registered handlers
|
|
152
|
+
*
|
|
153
|
+
* This is typically called by a worker/queue processor,
|
|
154
|
+
* but can also be called synchronously for simple cases.
|
|
155
|
+
*/
|
|
156
|
+
async processEvent(eventId) {
|
|
157
|
+
// Fetch the event with its type and handlers
|
|
158
|
+
const event = await this.prisma.workflowEvent.findUnique({
|
|
159
|
+
where: { id: eventId },
|
|
160
|
+
include: {
|
|
161
|
+
eventType: {
|
|
162
|
+
include: {
|
|
163
|
+
handlers: {
|
|
164
|
+
where: { enabled: true },
|
|
165
|
+
orderBy: { priority: 'asc' },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (!event) {
|
|
172
|
+
throw new Error(`Event ${eventId} not found`);
|
|
173
|
+
}
|
|
174
|
+
// Mark as processing
|
|
175
|
+
await this.prisma.workflowEvent.update({
|
|
176
|
+
where: { id: eventId },
|
|
177
|
+
data: { status: 'PROCESSING' },
|
|
178
|
+
});
|
|
179
|
+
const handlers = event.eventType.handlers;
|
|
180
|
+
const errors = [];
|
|
181
|
+
let handlersSucceeded = 0;
|
|
182
|
+
let handlersFailed = 0;
|
|
183
|
+
// Execute each handler in priority order
|
|
184
|
+
for (const handler of handlers) {
|
|
185
|
+
// Check filter condition
|
|
186
|
+
if (handler.filterCondition) {
|
|
187
|
+
const shouldRun = this.evaluateFilterCondition(handler.filterCondition, event.payload);
|
|
188
|
+
if (!shouldRun) {
|
|
189
|
+
// Log as skipped
|
|
190
|
+
await this.prisma.eventHandlerExecution.create({
|
|
191
|
+
data: {
|
|
192
|
+
eventId: event.id,
|
|
193
|
+
handlerId: handler.id,
|
|
194
|
+
status: 'SKIPPED',
|
|
195
|
+
input: event.payload,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Create execution record
|
|
202
|
+
const execution = await this.prisma.eventHandlerExecution.create({
|
|
203
|
+
data: {
|
|
204
|
+
eventId: event.id,
|
|
205
|
+
handlerId: handler.id,
|
|
206
|
+
status: 'RUNNING',
|
|
207
|
+
input: event.payload,
|
|
208
|
+
startedAt: new Date(),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
// Execute the handler based on type
|
|
213
|
+
const output = await this.executeHandler(handler.handlerType, handler.config, event.payload, event.tenantId);
|
|
214
|
+
// Mark as completed
|
|
215
|
+
const completedAt = new Date();
|
|
216
|
+
await this.prisma.eventHandlerExecution.update({
|
|
217
|
+
where: { id: execution.id },
|
|
218
|
+
data: {
|
|
219
|
+
status: 'COMPLETED',
|
|
220
|
+
output: output,
|
|
221
|
+
completedAt,
|
|
222
|
+
durationMs: completedAt.getTime() - execution.startedAt.getTime(),
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
handlersSucceeded++;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// Mark as failed
|
|
229
|
+
const completedAt = new Date();
|
|
230
|
+
await this.prisma.eventHandlerExecution.update({
|
|
231
|
+
where: { id: execution.id },
|
|
232
|
+
data: {
|
|
233
|
+
status: 'FAILED',
|
|
234
|
+
error: error.message,
|
|
235
|
+
errorCode: error.code,
|
|
236
|
+
completedAt,
|
|
237
|
+
durationMs: completedAt.getTime() - execution.startedAt.getTime(),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
errors.push({
|
|
241
|
+
handlerId: handler.id,
|
|
242
|
+
handlerName: handler.name,
|
|
243
|
+
error: error.message,
|
|
244
|
+
});
|
|
245
|
+
handlersFailed++;
|
|
246
|
+
// TODO: Implement retry logic based on handler.maxRetries and handler.retryDelayMs
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Update event status
|
|
250
|
+
const finalStatus = handlersFailed > 0
|
|
251
|
+
? handlersSucceeded > 0
|
|
252
|
+
? 'COMPLETED' // Partial success still counts as completed
|
|
253
|
+
: 'FAILED'
|
|
254
|
+
: 'COMPLETED';
|
|
255
|
+
await this.prisma.workflowEvent.update({
|
|
256
|
+
where: { id: eventId },
|
|
257
|
+
data: {
|
|
258
|
+
status: finalStatus,
|
|
259
|
+
processedAt: new Date(),
|
|
260
|
+
error: errors.length > 0 ? JSON.stringify(errors) : null,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
eventId,
|
|
265
|
+
status: finalStatus,
|
|
266
|
+
handlersExecuted: handlers.length,
|
|
267
|
+
handlersSucceeded,
|
|
268
|
+
handlersFailed,
|
|
269
|
+
errors,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get pending events for processing (for worker/queue)
|
|
274
|
+
*/
|
|
275
|
+
async getPendingEvents(tenantId, limit = 100) {
|
|
276
|
+
const events = await this.prisma.workflowEvent.findMany({
|
|
277
|
+
where: {
|
|
278
|
+
status: 'PENDING',
|
|
279
|
+
...(tenantId && { tenantId }),
|
|
280
|
+
},
|
|
281
|
+
include: {
|
|
282
|
+
eventType: {
|
|
283
|
+
include: { channel: true },
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
orderBy: { createdAt: 'asc' },
|
|
287
|
+
take: limit,
|
|
288
|
+
});
|
|
289
|
+
return events.map((event) => ({
|
|
290
|
+
id: event.id,
|
|
291
|
+
tenantId: event.tenantId,
|
|
292
|
+
eventTypeId: event.eventTypeId,
|
|
293
|
+
eventTypeCode: event.eventType.code,
|
|
294
|
+
channelCode: event.eventType.channel.code,
|
|
295
|
+
payload: event.payload,
|
|
296
|
+
source: event.source,
|
|
297
|
+
actorId: event.actorId,
|
|
298
|
+
actorType: event.actorType,
|
|
299
|
+
status: event.status,
|
|
300
|
+
correlationId: event.correlationId,
|
|
301
|
+
causationId: event.causationId,
|
|
302
|
+
error: event.error,
|
|
303
|
+
processedAt: event.processedAt,
|
|
304
|
+
createdAt: event.createdAt,
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get events by correlation ID (for tracing related events)
|
|
309
|
+
*/
|
|
310
|
+
async getEventsByCorrelation(tenantId, correlationId) {
|
|
311
|
+
const events = await this.prisma.workflowEvent.findMany({
|
|
312
|
+
where: { tenantId, correlationId },
|
|
313
|
+
include: {
|
|
314
|
+
eventType: {
|
|
315
|
+
include: { channel: true },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
orderBy: { createdAt: 'asc' },
|
|
319
|
+
});
|
|
320
|
+
return events.map((event) => ({
|
|
321
|
+
id: event.id,
|
|
322
|
+
tenantId: event.tenantId,
|
|
323
|
+
eventTypeId: event.eventTypeId,
|
|
324
|
+
eventTypeCode: event.eventType.code,
|
|
325
|
+
channelCode: event.eventType.channel.code,
|
|
326
|
+
payload: event.payload,
|
|
327
|
+
source: event.source,
|
|
328
|
+
actorId: event.actorId,
|
|
329
|
+
actorType: event.actorType,
|
|
330
|
+
status: event.status,
|
|
331
|
+
correlationId: event.correlationId,
|
|
332
|
+
causationId: event.causationId,
|
|
333
|
+
error: event.error,
|
|
334
|
+
processedAt: event.processedAt,
|
|
335
|
+
createdAt: event.createdAt,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get event with executions (for debugging/auditing)
|
|
340
|
+
*/
|
|
341
|
+
async getEventWithExecutions(tenantId, eventId) {
|
|
342
|
+
const event = await this.prisma.workflowEvent.findFirst({
|
|
343
|
+
where: { id: eventId, tenantId },
|
|
344
|
+
include: {
|
|
345
|
+
eventType: {
|
|
346
|
+
include: { channel: true },
|
|
347
|
+
},
|
|
348
|
+
executions: {
|
|
349
|
+
include: {
|
|
350
|
+
handler: {
|
|
351
|
+
select: { id: true, name: true, handlerType: true },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
orderBy: { createdAt: 'asc' },
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
return event;
|
|
359
|
+
}
|
|
360
|
+
// ==========================================
|
|
361
|
+
// HANDLER EXECUTION
|
|
362
|
+
// ==========================================
|
|
363
|
+
/**
|
|
364
|
+
* Execute a handler based on its type
|
|
365
|
+
*
|
|
366
|
+
* Handler types are business-friendly names that abstract the underlying implementation:
|
|
367
|
+
* - SEND_EMAIL: Send email via notification service (SNS → SQS → SES)
|
|
368
|
+
* - SEND_SMS: Send SMS via notification service
|
|
369
|
+
* - SEND_PUSH: Send push notification via notification service
|
|
370
|
+
* - CALL_WEBHOOK: Make HTTP request to external URL
|
|
371
|
+
* - ADVANCE_WORKFLOW: Move workflow steps forward/backward
|
|
372
|
+
* - RUN_AUTOMATION: Execute registered business logic automation
|
|
373
|
+
*/
|
|
374
|
+
async executeHandler(handlerType, config, payload, tenantId) {
|
|
375
|
+
switch (handlerType) {
|
|
376
|
+
case 'SEND_EMAIL':
|
|
377
|
+
return this.executeSendEmailHandler(config, payload, tenantId);
|
|
378
|
+
case 'SEND_SMS':
|
|
379
|
+
return this.executeSendSmsHandler(config, payload, tenantId);
|
|
380
|
+
case 'SEND_PUSH':
|
|
381
|
+
return this.executeSendPushHandler(config, payload, tenantId);
|
|
382
|
+
case 'CALL_WEBHOOK':
|
|
383
|
+
return this.executeCallWebhookHandler(config, payload);
|
|
384
|
+
case 'ADVANCE_WORKFLOW':
|
|
385
|
+
return this.executeAdvanceWorkflowHandler(config, payload, tenantId);
|
|
386
|
+
case 'RUN_AUTOMATION':
|
|
387
|
+
return this.executeRunAutomationHandler(config, payload, tenantId);
|
|
388
|
+
default:
|
|
389
|
+
throw new Error(`Unknown handler type: ${handlerType}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Execute SEND_EMAIL handler
|
|
394
|
+
*
|
|
395
|
+
* Sends an email via the notification service using SNS → SQS → SES.
|
|
396
|
+
* Business users configure: template, recipient, and template data.
|
|
397
|
+
*/
|
|
398
|
+
async executeSendEmailHandler(config, payload, tenantId) {
|
|
399
|
+
// Build the notification payload
|
|
400
|
+
const notificationPayload = this.buildNotificationPayload(config, payload);
|
|
401
|
+
// Resolve recipient email from the payload
|
|
402
|
+
if (config.recipientPath) {
|
|
403
|
+
const email = this.resolvePath(payload, config.recipientPath.replace(/^\$\./, ''));
|
|
404
|
+
if (email && typeof email === 'string') {
|
|
405
|
+
notificationPayload.to_email = email;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Publish to SNS via EventPublisher
|
|
409
|
+
const messageId = await this.eventPublisher.publish(config.notificationType, NotificationChannel.EMAIL, notificationPayload, {
|
|
410
|
+
tenantId,
|
|
411
|
+
correlationId: payload.correlationId || undefined,
|
|
412
|
+
userId: payload.userId || payload.actorId || undefined,
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
success: true,
|
|
416
|
+
messageId,
|
|
417
|
+
notificationType: config.notificationType,
|
|
418
|
+
channel: 'email',
|
|
419
|
+
payload: notificationPayload,
|
|
420
|
+
tenantId,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Execute SEND_SMS handler
|
|
425
|
+
*
|
|
426
|
+
* Sends an SMS via the notification service.
|
|
427
|
+
* Business users configure: template, recipient phone, and template data.
|
|
428
|
+
*/
|
|
429
|
+
async executeSendSmsHandler(config, payload, tenantId) {
|
|
430
|
+
// Build the notification payload
|
|
431
|
+
const notificationPayload = this.buildNotificationPayload(config, payload);
|
|
432
|
+
// Resolve recipient phone from the payload
|
|
433
|
+
if (config.recipientPath) {
|
|
434
|
+
const phone = this.resolvePath(payload, config.recipientPath.replace(/^\$\./, ''));
|
|
435
|
+
if (phone && typeof phone === 'string') {
|
|
436
|
+
notificationPayload.to_phone = phone;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Publish to SNS via EventPublisher
|
|
440
|
+
const messageId = await this.eventPublisher.publish(config.notificationType, NotificationChannel.SMS, notificationPayload, {
|
|
441
|
+
tenantId,
|
|
442
|
+
correlationId: payload.correlationId || undefined,
|
|
443
|
+
userId: payload.userId || payload.actorId || undefined,
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
messageId,
|
|
448
|
+
notificationType: config.notificationType,
|
|
449
|
+
channel: 'sms',
|
|
450
|
+
payload: notificationPayload,
|
|
451
|
+
tenantId,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Execute SEND_PUSH handler
|
|
456
|
+
*
|
|
457
|
+
* Sends a push notification via the notification service.
|
|
458
|
+
* Business users configure: template, recipient user, and template data.
|
|
459
|
+
*/
|
|
460
|
+
async executeSendPushHandler(config, payload, tenantId) {
|
|
461
|
+
// Build the notification payload
|
|
462
|
+
const notificationPayload = this.buildNotificationPayload(config, payload);
|
|
463
|
+
// Resolve recipient user ID from the payload
|
|
464
|
+
if (config.recipientPath) {
|
|
465
|
+
const userId = this.resolvePath(payload, config.recipientPath.replace(/^\$\./, ''));
|
|
466
|
+
if (userId && typeof userId === 'string') {
|
|
467
|
+
notificationPayload.to_user_id = userId;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Publish to SNS via EventPublisher
|
|
471
|
+
const messageId = await this.eventPublisher.publish(config.notificationType, NotificationChannel.PUSH, notificationPayload, {
|
|
472
|
+
tenantId,
|
|
473
|
+
correlationId: payload.correlationId || undefined,
|
|
474
|
+
userId: payload.userId || payload.actorId || undefined,
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
success: true,
|
|
478
|
+
messageId,
|
|
479
|
+
notificationType: config.notificationType,
|
|
480
|
+
channel: 'push',
|
|
481
|
+
payload: notificationPayload,
|
|
482
|
+
tenantId,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Build notification payload from config and event payload
|
|
487
|
+
*/
|
|
488
|
+
buildNotificationPayload(config, payload) {
|
|
489
|
+
const result = {};
|
|
490
|
+
// Apply static template data first
|
|
491
|
+
if (config.staticData) {
|
|
492
|
+
Object.assign(result, config.staticData);
|
|
493
|
+
}
|
|
494
|
+
// Apply template data mapping (map from event payload to notification payload)
|
|
495
|
+
if (config.templateData) {
|
|
496
|
+
for (const [targetField, sourcePath] of Object.entries(config.templateData)) {
|
|
497
|
+
const value = this.resolvePath(payload, sourcePath.replace(/^\$\./, ''));
|
|
498
|
+
if (value !== undefined) {
|
|
499
|
+
result[targetField] = value;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Execute CALL_WEBHOOK handler
|
|
507
|
+
*
|
|
508
|
+
* Makes an HTTP request to an external URL.
|
|
509
|
+
* Business users configure: URL, method, headers, and body mapping.
|
|
510
|
+
*/
|
|
511
|
+
async executeCallWebhookHandler(config, payload) {
|
|
512
|
+
const transformedPayload = config.bodyMapping
|
|
513
|
+
? this.transformPayload(payload, config.bodyMapping)
|
|
514
|
+
: payload;
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
const timeoutId = setTimeout(() => controller.abort(), config.timeoutMs || 30000);
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(config.url, {
|
|
519
|
+
method: config.method,
|
|
520
|
+
headers: {
|
|
521
|
+
'Content-Type': 'application/json',
|
|
522
|
+
...config.headers,
|
|
523
|
+
},
|
|
524
|
+
body: config.method !== 'GET' ? JSON.stringify(transformedPayload) : undefined,
|
|
525
|
+
signal: controller.signal,
|
|
526
|
+
});
|
|
527
|
+
clearTimeout(timeoutId);
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
const errorText = await response.text();
|
|
530
|
+
throw new Error(`Webhook returned ${response.status}: ${errorText}`);
|
|
531
|
+
}
|
|
532
|
+
// Try to parse JSON response, fall back to empty object
|
|
533
|
+
try {
|
|
534
|
+
return await response.json();
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return {};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
clearTimeout(timeoutId);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Execute ADVANCE_WORKFLOW handler
|
|
546
|
+
*
|
|
547
|
+
* Advances or modifies workflow state.
|
|
548
|
+
* Business users configure: action (approve/reject/skip), step path, and data.
|
|
549
|
+
*/
|
|
550
|
+
async executeAdvanceWorkflowHandler(config, payload, tenantId) {
|
|
551
|
+
// Resolve step ID from payload if path is provided
|
|
552
|
+
let stepId = config.stepId;
|
|
553
|
+
if (config.stepIdPath) {
|
|
554
|
+
const resolved = this.resolvePath(payload, config.stepIdPath.replace(/^\$\./, ''));
|
|
555
|
+
if (resolved && typeof resolved === 'string') {
|
|
556
|
+
stepId = resolved;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Return the workflow action data
|
|
560
|
+
// The workflow service should listen for ADVANCE_WORKFLOW handler results
|
|
561
|
+
return {
|
|
562
|
+
action: config.action,
|
|
563
|
+
workflowId: config.workflowId,
|
|
564
|
+
phaseId: config.phaseId,
|
|
565
|
+
stepId,
|
|
566
|
+
data: { ...config.data, ...payload },
|
|
567
|
+
tenantId,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Execute RUN_AUTOMATION handler
|
|
572
|
+
*
|
|
573
|
+
* Runs a registered business logic automation.
|
|
574
|
+
* Business users select from pre-defined automations like
|
|
575
|
+
* "Calculate Mortgage Payment", "Generate Contract", etc.
|
|
576
|
+
*/
|
|
577
|
+
async executeRunAutomationHandler(config, payload, tenantId) {
|
|
578
|
+
// Get the automation function from the registry
|
|
579
|
+
const automationFn = this.automationRegistry.get(config.automation);
|
|
580
|
+
if (!automationFn) {
|
|
581
|
+
throw new Error(`Automation '${config.automation}' not found in registry`);
|
|
582
|
+
}
|
|
583
|
+
// Transform payload if mapping is defined
|
|
584
|
+
const transformedPayload = config.inputMapping
|
|
585
|
+
? this.transformPayload(payload, config.inputMapping)
|
|
586
|
+
: payload;
|
|
587
|
+
// Call the automation function with inputs and tenantId
|
|
588
|
+
return automationFn(transformedPayload, tenantId);
|
|
589
|
+
}
|
|
590
|
+
// ==========================================
|
|
591
|
+
// UTILITY METHODS
|
|
592
|
+
// ==========================================
|
|
593
|
+
/**
|
|
594
|
+
* Evaluate a filter condition against the payload
|
|
595
|
+
*/
|
|
596
|
+
evaluateFilterCondition(condition, payload) {
|
|
597
|
+
try {
|
|
598
|
+
// Simple JSONPath-like evaluation
|
|
599
|
+
// Supports: $.field == 'value', $.field != 'value', $.field > 10, etc.
|
|
600
|
+
// Equality: $.field == 'value'
|
|
601
|
+
const eqMatch = condition.match(/^\$\.(\w+(?:\.\w+)*)\s*==\s*['"](.+)['"]$/);
|
|
602
|
+
if (eqMatch) {
|
|
603
|
+
const [, path, value] = eqMatch;
|
|
604
|
+
return this.resolvePath(payload, path) === value;
|
|
605
|
+
}
|
|
606
|
+
// Inequality: $.field != 'value'
|
|
607
|
+
const neqMatch = condition.match(/^\$\.(\w+(?:\.\w+)*)\s*!=\s*['"](.+)['"]$/);
|
|
608
|
+
if (neqMatch) {
|
|
609
|
+
const [, path, value] = neqMatch;
|
|
610
|
+
return this.resolvePath(payload, path) !== value;
|
|
611
|
+
}
|
|
612
|
+
// Numeric comparison: $.field > 10
|
|
613
|
+
const numMatch = condition.match(/^\$\.(\w+(?:\.\w+)*)\s*([<>=!]+)\s*(\d+(?:\.\d+)?)$/);
|
|
614
|
+
if (numMatch) {
|
|
615
|
+
const [, path, op, numStr] = numMatch;
|
|
616
|
+
const fieldValue = this.resolvePath(payload, path);
|
|
617
|
+
const num = parseFloat(numStr);
|
|
618
|
+
if (typeof fieldValue !== 'number')
|
|
619
|
+
return false;
|
|
620
|
+
switch (op) {
|
|
621
|
+
case '>':
|
|
622
|
+
return fieldValue > num;
|
|
623
|
+
case '>=':
|
|
624
|
+
return fieldValue >= num;
|
|
625
|
+
case '<':
|
|
626
|
+
return fieldValue < num;
|
|
627
|
+
case '<=':
|
|
628
|
+
return fieldValue <= num;
|
|
629
|
+
case '==':
|
|
630
|
+
return fieldValue === num;
|
|
631
|
+
case '!=':
|
|
632
|
+
return fieldValue !== num;
|
|
633
|
+
default:
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Existence check: $.field (truthy check)
|
|
638
|
+
const existsMatch = condition.match(/^\$\.(\w+(?:\.\w+)*)$/);
|
|
639
|
+
if (existsMatch) {
|
|
640
|
+
const [, path] = existsMatch;
|
|
641
|
+
return !!this.resolvePath(payload, path);
|
|
642
|
+
}
|
|
643
|
+
// If we can't parse, run the handler (fail open)
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Transform payload using a mapping
|
|
652
|
+
*/
|
|
653
|
+
transformPayload(payload, mapping) {
|
|
654
|
+
const result = {};
|
|
655
|
+
for (const [targetKey, sourcePath] of Object.entries(mapping)) {
|
|
656
|
+
result[targetKey] = this.resolvePath(payload, sourcePath);
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Resolve a dot-notation path in an object
|
|
662
|
+
*/
|
|
663
|
+
resolvePath(obj, path) {
|
|
664
|
+
const parts = path.replace(/^\$\./, '').split('.');
|
|
665
|
+
let current = obj;
|
|
666
|
+
for (const part of parts) {
|
|
667
|
+
if (current == null)
|
|
668
|
+
return undefined;
|
|
669
|
+
current = current[part];
|
|
670
|
+
}
|
|
671
|
+
return current;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Create a workflow event service instance
|
|
676
|
+
*
|
|
677
|
+
* @param prisma - Prisma client for database access
|
|
678
|
+
* @param automationRegistry - Optional registry of business automations
|
|
679
|
+
*/
|
|
680
|
+
export function createWorkflowEventService(prisma, automationRegistry) {
|
|
681
|
+
return new WorkflowEventService(prisma, automationRegistry);
|
|
682
|
+
}
|