@valentine-efagene/qshelter-common 2.0.65 → 2.0.67

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