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