@critical-path/core 0.26.0 → 0.28.0
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/CHANGELOG.md +50 -0
- package/README.md +15 -0
- package/dist/domain/custom-fields.d.ts +7 -2
- package/dist/domain/custom-fields.d.ts.map +1 -1
- package/dist/domain/custom-fields.js +23 -1
- package/dist/domain/custom-fields.js.map +1 -1
- package/dist/domain/events.d.ts +5 -0
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +34 -0
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +30 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +133 -77
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +13 -3
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -15
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/plugins.test.d.ts +2 -0
- package/dist/plugins/plugins.test.d.ts.map +1 -0
- package/dist/plugins/plugins.test.js +137 -0
- package/dist/plugins/plugins.test.js.map +1 -0
- package/dist/schemas/index.d.ts +167 -121
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +15 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +1 -0
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/firebase.d.ts +3 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +21 -0
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +17 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +14 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +42 -8
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +67 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/dispatcher.d.ts +80 -0
- package/dist/webhooks/dispatcher.d.ts.map +1 -0
- package/dist/webhooks/dispatcher.js +133 -0
- package/dist/webhooks/dispatcher.js.map +1 -0
- package/dist/webhooks/index.d.ts +3 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +3 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/signature.d.ts +28 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +42 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/webhooks/webhooks.test.d.ts +2 -0
- package/dist/webhooks/webhooks.test.d.ts.map +1 -0
- package/dist/webhooks/webhooks.test.js +153 -0
- package/dist/webhooks/webhooks.test.js.map +1 -0
- package/package.json +1 -1
- package/src/domain/custom-fields.ts +43 -2
- package/src/domain/events.ts +42 -3
- package/src/engine/index.ts +170 -84
- package/src/index.ts +1 -0
- package/src/plugins/index.ts +51 -16
- package/src/plugins/plugins.test.ts +161 -0
- package/src/schemas/index.ts +19 -1
- package/src/schemas/schemas.test.ts +6 -3
- package/src/store/firebase.test.ts +20 -0
- package/src/store/firebase.ts +22 -0
- package/src/store/index.ts +19 -0
- package/src/store/sqlite.ts +54 -7
- package/src/types/index.ts +72 -33
- package/src/webhooks/dispatcher.ts +190 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/signature.ts +57 -0
- package/src/webhooks/webhooks.test.ts +200 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type {
|
|
|
17
17
|
PresignedUrlOptions,
|
|
18
18
|
PresignedUploadResult,
|
|
19
19
|
TimeEntry,
|
|
20
|
-
|
|
20
|
+
Webhook,
|
|
21
|
+
PublicWebhook,
|
|
21
22
|
Workflow,
|
|
22
23
|
CreateTaskInput,
|
|
23
24
|
CreateProjectInput,
|
|
@@ -82,9 +83,12 @@ import {
|
|
|
82
83
|
AttachmentDeletedEvent
|
|
83
84
|
} from '../domain/events.js';
|
|
84
85
|
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
85
|
-
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
86
|
+
import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
|
|
86
87
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
87
88
|
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
89
|
+
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
90
|
+
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
91
|
+
import type { DomainEvent } from '../domain/events.js';
|
|
88
92
|
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
89
93
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
90
94
|
import {
|
|
@@ -100,6 +104,8 @@ export class CriticalPathEngine {
|
|
|
100
104
|
public readonly fileStorage?: FileStorageAdapter;
|
|
101
105
|
public readonly plugins: PluginRegistry;
|
|
102
106
|
public readonly events: DomainEventBus;
|
|
107
|
+
/** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
|
|
108
|
+
public readonly webhooks: WebhookDispatcher;
|
|
103
109
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
104
110
|
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
105
111
|
public readonly actor?: Actor;
|
|
@@ -115,6 +121,14 @@ export class CriticalPathEngine {
|
|
|
115
121
|
this.fileStorage = config.fileStorage;
|
|
116
122
|
this.plugins = new PluginRegistry();
|
|
117
123
|
this.events = new DomainEventBus();
|
|
124
|
+
this.webhooks = new WebhookDispatcher(
|
|
125
|
+
{
|
|
126
|
+
listWebhooks: () => this.listAllWebhooks(),
|
|
127
|
+
resolveTenant: (event) => this.resolveEventTenant(event)
|
|
128
|
+
},
|
|
129
|
+
config.webhookDelivery
|
|
130
|
+
);
|
|
131
|
+
this.events.subscribe('*', (event) => this.webhooks.handle(event));
|
|
118
132
|
|
|
119
133
|
if (config.plugins) {
|
|
120
134
|
for (const plugin of config.plugins) {
|
|
@@ -122,9 +136,9 @@ export class CriticalPathEngine {
|
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
139
|
+
this.ready = this.initialize(config);
|
|
140
|
+
// Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
|
|
141
|
+
this.ready.catch(() => {});
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
@@ -250,6 +264,15 @@ export class CriticalPathEngine {
|
|
|
250
264
|
return rest as T;
|
|
251
265
|
}
|
|
252
266
|
|
|
267
|
+
private async initialize(config: CriticalPathConfig): Promise<void> {
|
|
268
|
+
if (config.initialData) {
|
|
269
|
+
await this.seedInitialData(config.initialData);
|
|
270
|
+
}
|
|
271
|
+
for (const plugin of this.plugins.getPlugins()) {
|
|
272
|
+
await plugin.init?.(this);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
253
276
|
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
254
277
|
if (data.workflows) {
|
|
255
278
|
for (const wf of data.workflows) {
|
|
@@ -319,7 +342,6 @@ export class CriticalPathEngine {
|
|
|
319
342
|
action: 'workflow.created',
|
|
320
343
|
details: { name: created.name }
|
|
321
344
|
});
|
|
322
|
-
this.dispatchWebhook('workflow.created', { workflow: created });
|
|
323
345
|
return created;
|
|
324
346
|
}
|
|
325
347
|
|
|
@@ -347,7 +369,6 @@ export class CriticalPathEngine {
|
|
|
347
369
|
action: 'workflow.updated',
|
|
348
370
|
details: { name: updated.name }
|
|
349
371
|
});
|
|
350
|
-
this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
|
|
351
372
|
}
|
|
352
373
|
return updated;
|
|
353
374
|
}
|
|
@@ -366,7 +387,7 @@ export class CriticalPathEngine {
|
|
|
366
387
|
aggregateId: id,
|
|
367
388
|
aggregateType: 'Workflow',
|
|
368
389
|
occurredAt: now,
|
|
369
|
-
payload: { workflowId: id, name: existing.name }
|
|
390
|
+
payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
|
|
370
391
|
};
|
|
371
392
|
await this.events.publish(event);
|
|
372
393
|
|
|
@@ -375,7 +396,6 @@ export class CriticalPathEngine {
|
|
|
375
396
|
action: 'workflow.deleted',
|
|
376
397
|
details: { name: existing.name }
|
|
377
398
|
});
|
|
378
|
-
this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
|
|
379
399
|
}
|
|
380
400
|
return deleted;
|
|
381
401
|
}
|
|
@@ -431,6 +451,7 @@ export class CriticalPathEngine {
|
|
|
431
451
|
|
|
432
452
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
433
453
|
await this.requireWorkspaceAccess('project.create');
|
|
454
|
+
validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
|
|
434
455
|
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
435
456
|
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
436
457
|
// The creator administers the project they create.
|
|
@@ -456,7 +477,6 @@ export class CriticalPathEngine {
|
|
|
456
477
|
action: 'project.created',
|
|
457
478
|
details: { name: created.name, key: created.key }
|
|
458
479
|
});
|
|
459
|
-
this.dispatchWebhook('project.created', { project: created });
|
|
460
480
|
return created;
|
|
461
481
|
}
|
|
462
482
|
|
|
@@ -465,6 +485,7 @@ export class CriticalPathEngine {
|
|
|
465
485
|
if (!existing) return null;
|
|
466
486
|
await this.requireProjectAccess('project.update', id);
|
|
467
487
|
if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
|
|
488
|
+
validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
|
|
468
489
|
updates = this.withoutTenant(updates);
|
|
469
490
|
|
|
470
491
|
const updated = await this.store.updateProject(id, updates);
|
|
@@ -486,7 +507,6 @@ export class CriticalPathEngine {
|
|
|
486
507
|
action: 'project.updated',
|
|
487
508
|
details: { name: updated.name }
|
|
488
509
|
});
|
|
489
|
-
this.dispatchWebhook('project.updated', { project: updated });
|
|
490
510
|
}
|
|
491
511
|
return updated;
|
|
492
512
|
}
|
|
@@ -519,7 +539,7 @@ export class CriticalPathEngine {
|
|
|
519
539
|
aggregateId: id,
|
|
520
540
|
aggregateType: 'Project',
|
|
521
541
|
occurredAt: now,
|
|
522
|
-
payload: { projectId: id, name: existing.name, deletedTaskIds }
|
|
542
|
+
payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
|
|
523
543
|
};
|
|
524
544
|
await this.events.publish(event);
|
|
525
545
|
|
|
@@ -529,7 +549,6 @@ export class CriticalPathEngine {
|
|
|
529
549
|
action: 'project.deleted',
|
|
530
550
|
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
531
551
|
});
|
|
532
|
-
this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
|
|
533
552
|
}
|
|
534
553
|
return deleted;
|
|
535
554
|
}
|
|
@@ -553,17 +572,19 @@ export class CriticalPathEngine {
|
|
|
553
572
|
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
554
573
|
}
|
|
555
574
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
556
|
-
|
|
575
|
+
if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
|
|
576
|
+
throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
|
|
577
|
+
}
|
|
578
|
+
const projectId = taskInput.projectId;
|
|
557
579
|
const project = await this.store.getProject(projectId);
|
|
558
580
|
const workflow = await this.resolveProjectWorkflow(projectId);
|
|
559
581
|
|
|
560
|
-
// Validate custom
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
582
|
+
// Validate custom fields on the final (post-plugin) input, including required fields when none are given
|
|
583
|
+
validateCustomFieldValues(
|
|
584
|
+
project?.customFieldDefinitions,
|
|
585
|
+
processedInput.customFields ?? taskInput.customFields,
|
|
586
|
+
this.plugins.getCustomFieldTypes()
|
|
587
|
+
);
|
|
567
588
|
|
|
568
589
|
const defaultStatus = workflow?.defaultStatusKey || 'todo';
|
|
569
590
|
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
@@ -642,7 +663,6 @@ export class CriticalPathEngine {
|
|
|
642
663
|
details: { title: created.title, status: created.status }
|
|
643
664
|
});
|
|
644
665
|
|
|
645
|
-
this.dispatchWebhook('task.created', { task: created });
|
|
646
666
|
return created;
|
|
647
667
|
}
|
|
648
668
|
|
|
@@ -673,24 +693,32 @@ export class CriticalPathEngine {
|
|
|
673
693
|
const project = await this.store.getProject(existing.projectId);
|
|
674
694
|
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
675
695
|
|
|
696
|
+
// Plugins run first; their output is then validated like caller input. Identity and
|
|
697
|
+
// ownership fields are never changed by hooks.
|
|
698
|
+
const {
|
|
699
|
+
id: _id,
|
|
700
|
+
projectId: _projectId,
|
|
701
|
+
createdAt: _createdAt,
|
|
702
|
+
...processedUpdates
|
|
703
|
+
} = await this.plugins.runBeforeTaskUpdate(id, taskUpdates) as Partial<Task>;
|
|
704
|
+
|
|
676
705
|
// Validate workflow transition invariant
|
|
677
|
-
if (
|
|
678
|
-
const isValid = validateTransition(workflow || undefined, existing.status,
|
|
706
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
707
|
+
const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
|
|
679
708
|
if (!isValid) {
|
|
680
|
-
throw new WorkflowValidationError(existing.status,
|
|
709
|
+
throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
|
|
681
710
|
}
|
|
682
711
|
}
|
|
683
712
|
|
|
684
713
|
// Validate custom field invariants
|
|
685
|
-
if (
|
|
686
|
-
validateCustomFieldValues(
|
|
687
|
-
|
|
688
|
-
...
|
|
689
|
-
|
|
714
|
+
if (processedUpdates.customFields) {
|
|
715
|
+
validateCustomFieldValues(
|
|
716
|
+
project?.customFieldDefinitions,
|
|
717
|
+
{ ...existing.customFields, ...processedUpdates.customFields },
|
|
718
|
+
this.plugins.getCustomFieldTypes()
|
|
719
|
+
);
|
|
690
720
|
}
|
|
691
721
|
|
|
692
|
-
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
693
|
-
|
|
694
722
|
const now = new Date().toISOString();
|
|
695
723
|
const existingStatusDef = resolveStatusDefinition(
|
|
696
724
|
existing.status,
|
|
@@ -925,10 +953,6 @@ export class CriticalPathEngine {
|
|
|
925
953
|
action: 'task.blocked',
|
|
926
954
|
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
927
955
|
});
|
|
928
|
-
this.dispatchWebhook('task.blocked', {
|
|
929
|
-
task: updated,
|
|
930
|
-
reason: blockedReason
|
|
931
|
-
});
|
|
932
956
|
} else if (wasBlocked && !isNowBlocked) {
|
|
933
957
|
const unblockedEvent: TaskUnblockedEvent = {
|
|
934
958
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -951,9 +975,6 @@ export class CriticalPathEngine {
|
|
|
951
975
|
action: 'task.unblocked',
|
|
952
976
|
details: { ...(actorName ? { actorName } : {}) }
|
|
953
977
|
});
|
|
954
|
-
this.dispatchWebhook('task.unblocked', {
|
|
955
|
-
task: updated
|
|
956
|
-
});
|
|
957
978
|
}
|
|
958
979
|
|
|
959
980
|
await this.store.logActivity({
|
|
@@ -968,10 +989,6 @@ export class CriticalPathEngine {
|
|
|
968
989
|
}
|
|
969
990
|
});
|
|
970
991
|
|
|
971
|
-
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
|
972
|
-
task: updated,
|
|
973
|
-
previous: existing
|
|
974
|
-
});
|
|
975
992
|
|
|
976
993
|
return updated;
|
|
977
994
|
}
|
|
@@ -1042,10 +1059,6 @@ export class CriticalPathEngine {
|
|
|
1042
1059
|
details: { unblockedByTaskId: completedTask.id }
|
|
1043
1060
|
});
|
|
1044
1061
|
|
|
1045
|
-
this.dispatchWebhook('task.unblocked', {
|
|
1046
|
-
task: updatedDownstream,
|
|
1047
|
-
upstreamTaskId: completedTask.id
|
|
1048
|
-
});
|
|
1049
1062
|
}
|
|
1050
1063
|
}
|
|
1051
1064
|
} catch (err) {
|
|
@@ -1058,11 +1071,11 @@ export class CriticalPathEngine {
|
|
|
1058
1071
|
if (!existing) return false;
|
|
1059
1072
|
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
1060
1073
|
|
|
1061
|
-
await this.plugins.runBeforeTaskDelete(id);
|
|
1074
|
+
await this.plugins.runBeforeTaskDelete(id, existing);
|
|
1062
1075
|
const deleted = await this.store.deleteTask(id);
|
|
1063
1076
|
|
|
1064
1077
|
if (deleted) {
|
|
1065
|
-
await this.plugins.runAfterTaskDelete(id);
|
|
1078
|
+
await this.plugins.runAfterTaskDelete(id, existing);
|
|
1066
1079
|
const now = new Date().toISOString();
|
|
1067
1080
|
|
|
1068
1081
|
const event: TaskDeletedEvent = {
|
|
@@ -1086,7 +1099,6 @@ export class CriticalPathEngine {
|
|
|
1086
1099
|
action: 'task.deleted',
|
|
1087
1100
|
details: { title: existing.title }
|
|
1088
1101
|
});
|
|
1089
|
-
this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
|
|
1090
1102
|
}
|
|
1091
1103
|
return deleted;
|
|
1092
1104
|
}
|
|
@@ -1297,7 +1309,6 @@ export class CriticalPathEngine {
|
|
|
1297
1309
|
};
|
|
1298
1310
|
await this.events.publish(event);
|
|
1299
1311
|
|
|
1300
|
-
this.dispatchWebhook('comment.created', { comment: created });
|
|
1301
1312
|
return created;
|
|
1302
1313
|
}
|
|
1303
1314
|
|
|
@@ -1326,7 +1337,6 @@ export class CriticalPathEngine {
|
|
|
1326
1337
|
};
|
|
1327
1338
|
await this.events.publish(event);
|
|
1328
1339
|
|
|
1329
|
-
this.dispatchWebhook('comment.updated', { comment: updated });
|
|
1330
1340
|
return updated;
|
|
1331
1341
|
}
|
|
1332
1342
|
|
|
@@ -1351,7 +1361,6 @@ export class CriticalPathEngine {
|
|
|
1351
1361
|
};
|
|
1352
1362
|
await this.events.publish(event);
|
|
1353
1363
|
|
|
1354
|
-
this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
|
|
1355
1364
|
}
|
|
1356
1365
|
return deleted;
|
|
1357
1366
|
}
|
|
@@ -1399,7 +1408,6 @@ export class CriticalPathEngine {
|
|
|
1399
1408
|
};
|
|
1400
1409
|
await this.events.publish(event);
|
|
1401
1410
|
|
|
1402
|
-
this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
|
|
1403
1411
|
return updated;
|
|
1404
1412
|
}
|
|
1405
1413
|
|
|
@@ -1432,7 +1440,6 @@ export class CriticalPathEngine {
|
|
|
1432
1440
|
};
|
|
1433
1441
|
await this.events.publish(event);
|
|
1434
1442
|
|
|
1435
|
-
this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
|
|
1436
1443
|
return updated;
|
|
1437
1444
|
}
|
|
1438
1445
|
|
|
@@ -1481,7 +1488,6 @@ export class CriticalPathEngine {
|
|
|
1481
1488
|
};
|
|
1482
1489
|
await this.events.publish(event);
|
|
1483
1490
|
|
|
1484
|
-
this.dispatchWebhook('attachment.created', { attachment: created });
|
|
1485
1491
|
return created;
|
|
1486
1492
|
}
|
|
1487
1493
|
|
|
@@ -1510,11 +1516,15 @@ export class CriticalPathEngine {
|
|
|
1510
1516
|
aggregateId: id,
|
|
1511
1517
|
aggregateType: 'Attachment',
|
|
1512
1518
|
occurredAt: now,
|
|
1513
|
-
payload: {
|
|
1519
|
+
payload: {
|
|
1520
|
+
attachmentId: id,
|
|
1521
|
+
storageKey: existing.storageKey,
|
|
1522
|
+
url: existing.url,
|
|
1523
|
+
projectId: await this.projectIdOfAttachment(existing)
|
|
1524
|
+
}
|
|
1514
1525
|
};
|
|
1515
1526
|
await this.events.publish(event);
|
|
1516
1527
|
|
|
1517
|
-
this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
|
|
1518
1528
|
}
|
|
1519
1529
|
return deleted;
|
|
1520
1530
|
}
|
|
@@ -1639,7 +1649,6 @@ export class CriticalPathEngine {
|
|
|
1639
1649
|
};
|
|
1640
1650
|
await this.events.publish(event);
|
|
1641
1651
|
|
|
1642
|
-
this.dispatchWebhook('team.created', { team: created });
|
|
1643
1652
|
return created;
|
|
1644
1653
|
}
|
|
1645
1654
|
|
|
@@ -1682,7 +1691,6 @@ export class CriticalPathEngine {
|
|
|
1682
1691
|
};
|
|
1683
1692
|
await this.events.publish(event);
|
|
1684
1693
|
|
|
1685
|
-
this.dispatchWebhook('container.created', { container: created });
|
|
1686
1694
|
return created;
|
|
1687
1695
|
}
|
|
1688
1696
|
|
|
@@ -1716,7 +1724,7 @@ export class CriticalPathEngine {
|
|
|
1716
1724
|
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1717
1725
|
const project = await this.store.getProject(input.projectId);
|
|
1718
1726
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1719
|
-
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
1727
|
+
validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
|
|
1720
1728
|
}
|
|
1721
1729
|
|
|
1722
1730
|
const created = await this.store.createDeliverable({
|
|
@@ -1743,7 +1751,6 @@ export class CriticalPathEngine {
|
|
|
1743
1751
|
action: 'deliverable.created',
|
|
1744
1752
|
details: { title: created.title, format: created.format }
|
|
1745
1753
|
});
|
|
1746
|
-
this.dispatchWebhook('deliverable.created', { deliverable: created });
|
|
1747
1754
|
return created;
|
|
1748
1755
|
}
|
|
1749
1756
|
|
|
@@ -1774,11 +1781,6 @@ export class CriticalPathEngine {
|
|
|
1774
1781
|
}
|
|
1775
1782
|
};
|
|
1776
1783
|
await this.events.publish(statusEvent);
|
|
1777
|
-
this.dispatchWebhook('deliverable.status_changed', {
|
|
1778
|
-
deliverable: updated,
|
|
1779
|
-
previousStatus: existing.status,
|
|
1780
|
-
newStatus: updated.status
|
|
1781
|
-
});
|
|
1782
1784
|
}
|
|
1783
1785
|
|
|
1784
1786
|
const updateEvent: DeliverableUpdatedEvent = {
|
|
@@ -1797,7 +1799,6 @@ export class CriticalPathEngine {
|
|
|
1797
1799
|
action: 'deliverable.updated',
|
|
1798
1800
|
details: { title: updated.title, status: updated.status }
|
|
1799
1801
|
});
|
|
1800
|
-
this.dispatchWebhook('deliverable.updated', { deliverable: updated });
|
|
1801
1802
|
}
|
|
1802
1803
|
return updated;
|
|
1803
1804
|
}
|
|
@@ -1826,7 +1827,6 @@ export class CriticalPathEngine {
|
|
|
1826
1827
|
action: 'deliverable.deleted',
|
|
1827
1828
|
details: { title: existing.title }
|
|
1828
1829
|
});
|
|
1829
|
-
this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
|
|
1830
1830
|
}
|
|
1831
1831
|
return deleted;
|
|
1832
1832
|
}
|
|
@@ -1904,7 +1904,6 @@ export class CriticalPathEngine {
|
|
|
1904
1904
|
payload: { iteration: created }
|
|
1905
1905
|
};
|
|
1906
1906
|
await this.events.publish(event);
|
|
1907
|
-
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
1908
1907
|
}
|
|
1909
1908
|
return created;
|
|
1910
1909
|
}
|
|
@@ -1927,7 +1926,6 @@ export class CriticalPathEngine {
|
|
|
1927
1926
|
payload: { iteration: updated }
|
|
1928
1927
|
};
|
|
1929
1928
|
await this.events.publish(event);
|
|
1930
|
-
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
1931
1929
|
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
1932
1930
|
const event: IterationCompletedEvent = {
|
|
1933
1931
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1938,7 +1936,6 @@ export class CriticalPathEngine {
|
|
|
1938
1936
|
payload: { iteration: updated }
|
|
1939
1937
|
};
|
|
1940
1938
|
await this.events.publish(event);
|
|
1941
|
-
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
1942
1939
|
}
|
|
1943
1940
|
}
|
|
1944
1941
|
return updated;
|
|
@@ -2123,19 +2120,108 @@ export class CriticalPathEngine {
|
|
|
2123
2120
|
);
|
|
2124
2121
|
}
|
|
2125
2122
|
|
|
2126
|
-
|
|
2123
|
+
// --- Webhooks ---
|
|
2124
|
+
|
|
2125
|
+
/** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
|
|
2126
|
+
private async listAllWebhooks(): Promise<Webhook[]> {
|
|
2127
|
+
const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
|
|
2128
|
+
...w,
|
|
2129
|
+
id: `config_${i}`,
|
|
2130
|
+
createdAt: new Date(0).toISOString()
|
|
2131
|
+
}));
|
|
2132
|
+
return [...fromConfig, ...(await this.store.getWebhooks())];
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
|
|
2136
|
+
return { ...webhook, hasSecret: !!secret };
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
|
|
2140
|
+
async getWebhooks(): Promise<PublicWebhook[]> {
|
|
2141
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2127
2142
|
const webhooks = await this.store.getWebhooks();
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2143
|
+
return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
async getWebhook(id: string): Promise<PublicWebhook | null> {
|
|
2147
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2148
|
+
const webhook = await this.store.getWebhook(id);
|
|
2149
|
+
return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Registers a webhook. The signing secret is returned only here; generate one by omitting
|
|
2154
|
+
* `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
|
|
2155
|
+
*/
|
|
2156
|
+
async createWebhook(input: {
|
|
2157
|
+
name: string;
|
|
2158
|
+
url: string;
|
|
2159
|
+
events: Webhook['events'];
|
|
2160
|
+
secret?: string;
|
|
2161
|
+
active?: boolean;
|
|
2162
|
+
}): Promise<{ webhook: PublicWebhook; secret: string }> {
|
|
2163
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2164
|
+
this.validateWebhookUrl(input.url);
|
|
2165
|
+
const secret = input.secret ?? generateWebhookSecret();
|
|
2166
|
+
const created = await this.store.addWebhook({
|
|
2167
|
+
name: input.name,
|
|
2168
|
+
url: input.url,
|
|
2169
|
+
events: input.events,
|
|
2170
|
+
secret,
|
|
2171
|
+
active: input.active ?? true,
|
|
2172
|
+
tenantId: this.actor?.tenantId
|
|
2173
|
+
});
|
|
2174
|
+
this.webhooks.invalidate();
|
|
2175
|
+
return { webhook: this.toPublicWebhook(created), secret };
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async updateWebhook(
|
|
2179
|
+
id: string,
|
|
2180
|
+
updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
|
|
2181
|
+
): Promise<PublicWebhook | null> {
|
|
2182
|
+
if (!(await this.getWebhook(id))) return null;
|
|
2183
|
+
if (updates.url) this.validateWebhookUrl(updates.url);
|
|
2184
|
+
const { name, url, events, active, secret } = updates;
|
|
2185
|
+
const updated = await this.store.updateWebhook(
|
|
2186
|
+
id,
|
|
2187
|
+
Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
|
|
2188
|
+
);
|
|
2189
|
+
this.webhooks.invalidate();
|
|
2190
|
+
return updated ? this.toPublicWebhook(updated) : null;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
2194
|
+
if (!(await this.getWebhook(id))) return false;
|
|
2195
|
+
const deleted = await this.store.deleteWebhook(id);
|
|
2196
|
+
this.webhooks.invalidate();
|
|
2197
|
+
return deleted;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
private validateWebhookUrl(url: string): void {
|
|
2201
|
+
try {
|
|
2202
|
+
assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
throw new ValidationError((err as Error).message);
|
|
2138
2205
|
}
|
|
2139
2206
|
}
|
|
2140
|
-
}
|
|
2141
2207
|
|
|
2208
|
+
/** Finds the tenant an event belongs to, from the entity in its payload or its project. */
|
|
2209
|
+
private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
|
|
2210
|
+
const p = event.payload as Record<string, any>;
|
|
2211
|
+
const owner = p.project ?? p.workflow ?? p.team;
|
|
2212
|
+
if (owner) return owner.tenantId ?? undefined;
|
|
2213
|
+
// Deleted projects and workflows carry their tenant in the payload.
|
|
2214
|
+
if ('tenantId' in p) return p.tenantId ?? undefined;
|
|
2215
|
+
|
|
2216
|
+
const projectId: string | undefined =
|
|
2217
|
+
p.projectId ??
|
|
2218
|
+
p.task?.projectId ??
|
|
2219
|
+
p.deliverable?.projectId ??
|
|
2220
|
+
p.iteration?.projectId ??
|
|
2221
|
+
p.container?.projectId ??
|
|
2222
|
+
p.attachment?.projectId ??
|
|
2223
|
+
(await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
|
|
2224
|
+
if (!projectId) return undefined;
|
|
2225
|
+
return (await this.store.getProject(projectId))?.tenantId ?? undefined;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
package/src/index.ts
CHANGED
package/src/plugins/index.ts
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
import type { CriticalPathPlugin, Task } from '../types/index.js';
|
|
1
|
+
import type { CriticalPathPlugin, CustomFieldType, PluginMiddleware, PluginRoute, Task } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
export const BUILT_IN_CUSTOM_FIELD_TYPES = [
|
|
4
|
+
'text',
|
|
5
|
+
'number',
|
|
6
|
+
'date',
|
|
7
|
+
'boolean',
|
|
8
|
+
'single_select',
|
|
9
|
+
'multi_select',
|
|
10
|
+
'user'
|
|
11
|
+
] as const;
|
|
2
12
|
|
|
3
13
|
export class PluginRegistry {
|
|
4
14
|
private plugins = new Map<string, CriticalPathPlugin>();
|
|
15
|
+
private fieldTypes = new Map<string, CustomFieldType>();
|
|
5
16
|
|
|
6
17
|
register(plugin: CriticalPathPlugin): void {
|
|
7
18
|
if (this.plugins.has(plugin.id)) {
|
|
8
19
|
throw new Error(`Plugin with ID "${plugin.id}" is already registered.`);
|
|
9
20
|
}
|
|
21
|
+
for (const fieldType of plugin.customFieldTypes ?? []) {
|
|
22
|
+
if ((BUILT_IN_CUSTOM_FIELD_TYPES as readonly string[]).includes(fieldType.type) || this.fieldTypes.has(fieldType.type)) {
|
|
23
|
+
throw new Error(`Plugin "${plugin.id}" registers custom field type "${fieldType.type}", which already exists.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const fieldType of plugin.customFieldTypes ?? []) {
|
|
27
|
+
this.fieldTypes.set(fieldType.type, fieldType);
|
|
28
|
+
}
|
|
10
29
|
this.plugins.set(plugin.id, plugin);
|
|
11
30
|
}
|
|
12
31
|
|
|
@@ -14,6 +33,19 @@ export class PluginRegistry {
|
|
|
14
33
|
return Array.from(this.plugins.values());
|
|
15
34
|
}
|
|
16
35
|
|
|
36
|
+
/** Custom field types contributed by plugins, keyed by `type`. */
|
|
37
|
+
getCustomFieldTypes(): ReadonlyMap<string, CustomFieldType> {
|
|
38
|
+
return this.fieldTypes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getRoutes(): Array<PluginRoute & { pluginId: string }> {
|
|
42
|
+
return this.getPlugins().flatMap((p) => (p.routes ?? []).map((route) => ({ ...route, pluginId: p.id })));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getMiddleware(): PluginMiddleware[] {
|
|
46
|
+
return this.getPlugins().flatMap((p) => (p.middleware ? [p.middleware] : []));
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
async runBeforeTaskCreate(task: Partial<Task>): Promise<Partial<Task>> {
|
|
18
50
|
let currentTask = { ...task };
|
|
19
51
|
for (const plugin of this.plugins.values()) {
|
|
@@ -25,11 +57,7 @@ export class PluginRegistry {
|
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
async runAfterTaskCreate(task: Task): Promise<void> {
|
|
28
|
-
|
|
29
|
-
if (plugin.hooks?.afterTaskCreate) {
|
|
30
|
-
await plugin.hooks.afterTaskCreate(task);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
60
|
+
await this.runAfter('afterTaskCreate', (hooks) => hooks.afterTaskCreate?.(task));
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
async runBeforeTaskUpdate(id: string, updates: Partial<Task>): Promise<Partial<Task>> {
|
|
@@ -43,25 +71,32 @@ export class PluginRegistry {
|
|
|
43
71
|
}
|
|
44
72
|
|
|
45
73
|
async runAfterTaskUpdate(task: Task, previousState: Task): Promise<void> {
|
|
46
|
-
|
|
47
|
-
if (plugin.hooks?.afterTaskUpdate) {
|
|
48
|
-
await plugin.hooks.afterTaskUpdate(task, previousState);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
74
|
+
await this.runAfter('afterTaskUpdate', (hooks) => hooks.afterTaskUpdate?.(task, previousState));
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
async runBeforeTaskDelete(id: string): Promise<void> {
|
|
77
|
+
async runBeforeTaskDelete(id: string, task: Task): Promise<void> {
|
|
54
78
|
for (const plugin of this.plugins.values()) {
|
|
55
79
|
if (plugin.hooks?.beforeTaskDelete) {
|
|
56
|
-
await plugin.hooks.beforeTaskDelete(id);
|
|
80
|
+
await plugin.hooks.beforeTaskDelete(id, task);
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
84
|
|
|
61
|
-
async runAfterTaskDelete(id: string): Promise<void> {
|
|
85
|
+
async runAfterTaskDelete(id: string, task: Task): Promise<void> {
|
|
86
|
+
await this.runAfter('afterTaskDelete', (hooks) => hooks.afterTaskDelete?.(id, task));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Runs an after-hook on every plugin; the change is already stored, so errors are only logged. */
|
|
90
|
+
private async runAfter(
|
|
91
|
+
name: string,
|
|
92
|
+
call: (hooks: NonNullable<CriticalPathPlugin['hooks']>) => unknown
|
|
93
|
+
): Promise<void> {
|
|
62
94
|
for (const plugin of this.plugins.values()) {
|
|
63
|
-
if (plugin.hooks
|
|
64
|
-
|
|
95
|
+
if (!plugin.hooks) continue;
|
|
96
|
+
try {
|
|
97
|
+
await call(plugin.hooks);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(`[CriticalPath] Plugin "${plugin.id}" ${name} hook failed:`, err);
|
|
65
100
|
}
|
|
66
101
|
}
|
|
67
102
|
}
|