@critical-path/core 0.27.0 → 0.29.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -0
  3. package/dist/domain/custom-fields.d.ts +7 -2
  4. package/dist/domain/custom-fields.d.ts.map +1 -1
  5. package/dist/domain/custom-fields.js +23 -1
  6. package/dist/domain/custom-fields.js.map +1 -1
  7. package/dist/domain/events.d.ts +8 -2
  8. package/dist/domain/events.d.ts.map +1 -1
  9. package/dist/domain/events.js +1 -0
  10. package/dist/domain/events.js.map +1 -1
  11. package/dist/engine/cascade.test.d.ts +2 -0
  12. package/dist/engine/cascade.test.d.ts.map +1 -0
  13. package/dist/engine/cascade.test.js +99 -0
  14. package/dist/engine/cascade.test.js.map +1 -0
  15. package/dist/engine/index.d.ts +17 -1
  16. package/dist/engine/index.d.ts.map +1 -1
  17. package/dist/engine/index.js +120 -27
  18. package/dist/engine/index.js.map +1 -1
  19. package/dist/plugins/index.d.ts +13 -3
  20. package/dist/plugins/index.d.ts.map +1 -1
  21. package/dist/plugins/index.js +44 -15
  22. package/dist/plugins/index.js.map +1 -1
  23. package/dist/plugins/plugins.test.d.ts +2 -0
  24. package/dist/plugins/plugins.test.d.ts.map +1 -0
  25. package/dist/plugins/plugins.test.js +137 -0
  26. package/dist/plugins/plugins.test.js.map +1 -0
  27. package/dist/schemas/index.d.ts +96 -93
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas/index.js +2 -1
  30. package/dist/schemas/index.js.map +1 -1
  31. package/dist/store/firebase.d.ts +8 -0
  32. package/dist/store/firebase.d.ts.map +1 -1
  33. package/dist/store/firebase.js +30 -8
  34. package/dist/store/firebase.js.map +1 -1
  35. package/dist/store/index.d.ts +7 -0
  36. package/dist/store/index.d.ts.map +1 -1
  37. package/dist/store/index.js +9 -0
  38. package/dist/store/index.js.map +1 -1
  39. package/dist/store/sqlite.d.ts +3 -0
  40. package/dist/store/sqlite.d.ts.map +1 -1
  41. package/dist/store/sqlite.js +41 -24
  42. package/dist/store/sqlite.js.map +1 -1
  43. package/dist/types/index.d.ts +51 -5
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/webhooks/webhooks.test.js +2 -1
  46. package/dist/webhooks/webhooks.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/domain/custom-fields.ts +43 -2
  49. package/src/domain/events.ts +7 -0
  50. package/src/engine/cascade.test.ts +121 -0
  51. package/src/engine/index.ts +142 -30
  52. package/src/plugins/index.ts +51 -16
  53. package/src/plugins/plugins.test.ts +161 -0
  54. package/src/schemas/index.ts +2 -1
  55. package/src/store/firebase.ts +33 -8
  56. package/src/store/index.ts +16 -0
  57. package/src/store/sqlite.ts +56 -31
  58. package/src/types/index.ts +57 -5
  59. package/src/webhooks/webhooks.test.ts +2 -1
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,5 @@
1
- import type { CustomFieldDefinition } from '../types/index.js';
1
+ import type { CustomFieldDefinition, CustomFieldType } from '../types/index.js';
2
+ import { BUILT_IN_CUSTOM_FIELD_TYPES } from '../plugins/index.js';
2
3
 
3
4
  export class CustomFieldValidationError extends Error {
4
5
  public readonly fieldKey: string;
@@ -16,7 +17,8 @@ export class CustomFieldValidationError extends Error {
16
17
 
17
18
  export function validateCustomFieldValues(
18
19
  definitions: CustomFieldDefinition[] | undefined,
19
- values: Record<string, unknown> | undefined
20
+ values: Record<string, unknown> | undefined,
21
+ customTypes?: ReadonlyMap<string, CustomFieldType>
20
22
  ): void {
21
23
  if (!definitions || definitions.length === 0) return;
22
24
  const customValues = values || {};
@@ -126,6 +128,45 @@ export function validateCustomFieldValues(
126
128
  }
127
129
  }
128
130
  break;
131
+ default: {
132
+ const custom = customTypes?.get(def.type);
133
+ if (!custom) {
134
+ throw new CustomFieldValidationError(
135
+ `Custom field "${def.label || def.key}" has unknown type "${def.type}". Register it with a plugin's customFieldTypes.`,
136
+ def.key,
137
+ def.type,
138
+ value
139
+ );
140
+ }
141
+ const problem = custom.validate(value, def);
142
+ if (problem) {
143
+ throw new CustomFieldValidationError(
144
+ `Custom field "${def.label || def.key}" ${problem}`,
145
+ def.key,
146
+ def.type,
147
+ value
148
+ );
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Checks that every definition uses a built-in type or one registered by a plugin, so projects
157
+ * cannot be configured with fields that would reject every value.
158
+ */
159
+ export function validateCustomFieldDefinitions(
160
+ definitions: CustomFieldDefinition[] | undefined,
161
+ customTypes?: ReadonlyMap<string, CustomFieldType>
162
+ ): void {
163
+ for (const def of definitions ?? []) {
164
+ if (!(BUILT_IN_CUSTOM_FIELD_TYPES as readonly string[]).includes(def.type) && !customTypes?.has(def.type)) {
165
+ throw new CustomFieldValidationError(
166
+ `Custom field "${def.label || def.key}" has unknown type "${def.type}". Register it with a plugin's customFieldTypes.`,
167
+ def.key,
168
+ def.type
169
+ );
129
170
  }
130
171
  }
131
172
  }
@@ -124,6 +124,11 @@ export interface TaskDependencyAddedEvent extends DomainEvent<{ dependency: Task
124
124
  readonly aggregateType: 'Dependency';
125
125
  }
126
126
 
127
+ export interface TaskDependencyRemovedEvent extends DomainEvent<{ dependency: TaskDependency }> {
128
+ readonly name: 'dependency.removed';
129
+ readonly aggregateType: 'Dependency';
130
+ }
131
+
127
132
  export interface ProjectCreatedEvent extends DomainEvent<{ project: Project }> {
128
133
  readonly name: 'project.created';
129
134
  readonly aggregateType: 'Project';
@@ -210,6 +215,7 @@ export type CriticalPathDomainEvent =
210
215
  | AttachmentCreatedEvent
211
216
  | AttachmentDeletedEvent
212
217
  | TaskDependencyAddedEvent
218
+ | TaskDependencyRemovedEvent
213
219
  | ProjectCreatedEvent
214
220
  | ProjectUpdatedEvent
215
221
  | ProjectDeletedEvent
@@ -288,6 +294,7 @@ export const DOMAIN_EVENT_NAMES = [
288
294
  'deliverable.status_changed',
289
295
  'deliverable.updated',
290
296
  'dependency.added',
297
+ 'dependency.removed',
291
298
  'iteration.completed',
292
299
  'iteration.started',
293
300
  'project.created',
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ InMemoryStore,
5
+ SQLiteStore,
6
+ FirebaseStore,
7
+ InMemoryFirestoreMock,
8
+ InMemoryFileStore,
9
+ createRolePolicy,
10
+ ForbiddenError,
11
+ type StorageAdapter
12
+ } from '../index.js';
13
+
14
+ const stores: Array<[string, () => StorageAdapter]> = [
15
+ ['InMemoryStore', () => new InMemoryStore()],
16
+ ['SQLiteStore', () => new SQLiteStore({ filename: ':memory:' })],
17
+ ['FirebaseStore', () => new FirebaseStore({ db: new InMemoryFirestoreMock() })]
18
+ ];
19
+
20
+ describe.each(stores)('cascading deletes (%s)', (_name, makeStore) => {
21
+ async function setup() {
22
+ const fileStorage = new InMemoryFileStore();
23
+ const engine = new CriticalPathEngine({ store: makeStore(), fileStorage });
24
+ const project = await engine.createProject({ name: 'Cascade' });
25
+ const parent = await engine.createTask({ projectId: project.id, title: 'Parent' });
26
+ const child = await engine.createTask({ projectId: project.id, title: 'Child', parentId: parent.id });
27
+ const grandchild = await engine.createTask({ projectId: project.id, title: 'Grandchild', parentId: child.id });
28
+ const other = await engine.createTask({ projectId: project.id, title: 'Other' });
29
+ return { engine, fileStorage, project, parent, child, grandchild, other };
30
+ }
31
+
32
+ it('removes subtasks, dependencies, comments, attachments with files, and time entries', async () => {
33
+ const { engine, fileStorage, parent, child, grandchild, other } = await setup();
34
+ const dependency = await engine.addDependency({ taskId: other.id, dependsOnTaskId: parent.id, type: 'blocking' });
35
+ const comment = await engine.addComment({ taskId: parent.id, content: 'note', authorId: 'u1' });
36
+ const upload = await engine.uploadAttachmentFile({ taskId: parent.id, filename: 'spec.txt', data: 'hello', uploaderId: 'u1' });
37
+ const onComment = await engine.createAttachment({ commentId: comment.id, filename: 'c.png', url: 'https://example.com/c.png', uploaderId: 'u1', mimeType: 'image/png', sizeBytes: 1 });
38
+ const entry = await engine.logTime({ taskId: parent.id, hours: 2 });
39
+
40
+ expect(await engine.deleteTask(parent.id)).toBe(true);
41
+
42
+ for (const id of [parent.id, child.id, grandchild.id]) expect(await engine.getTask(id)).toBeNull();
43
+ expect(await engine.getTask(other.id)).not.toBeNull();
44
+ expect(await engine.store.getDependency(dependency.id)).toBeNull();
45
+ expect(await engine.store.getComment(comment.id)).toBeNull();
46
+ expect(await engine.store.getAttachment(upload.id)).toBeNull();
47
+ expect(await engine.store.getAttachment(onComment.id)).toBeNull();
48
+ expect(await fileStorage.download!(upload.storageKey!).catch(() => null)).toBeNull();
49
+ expect((await engine.store.getTimeEntries(parent.id)).map((e) => e.id)).not.toContain(entry.id);
50
+ });
51
+
52
+ it('can detach subtasks instead of deleting them', async () => {
53
+ const { engine, parent, child } = await setup();
54
+ await engine.deleteTask(parent.id, { subtasks: 'detach' });
55
+ const kept = await engine.getTask(child.id);
56
+ expect(kept).not.toBeNull();
57
+ expect(kept?.parentId).toBeUndefined();
58
+ });
59
+
60
+ it('deletes a project with all its planning records and reports every deleted task', async () => {
61
+ const { engine, project, parent, child, grandchild, other } = await setup();
62
+ const container = await engine.createContainer({ projectId: project.id, name: 'Epic' });
63
+ const iteration = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
64
+ const deliverable = await engine.createDeliverable({ projectId: project.id, title: 'Cut' });
65
+ const projectFile = await engine.createAttachment({ projectId: project.id, filename: 'brief.pdf', url: 'https://example.com/b.pdf', uploaderId: 'u1', mimeType: 'application/pdf', sizeBytes: 1 });
66
+
67
+ let deletedTaskIds: string[] = [];
68
+ engine.events.subscribe('project.deleted', (e) => {
69
+ deletedTaskIds = (e.payload as { deletedTaskIds: string[] }).deletedTaskIds;
70
+ });
71
+ expect(await engine.deleteProject(project.id)).toBe(true);
72
+
73
+ expect(deletedTaskIds.sort()).toEqual([parent.id, child.id, grandchild.id, other.id].sort());
74
+ expect(await engine.store.getContainer(container.id)).toBeNull();
75
+ expect(await engine.store.getIteration(iteration.id)).toBeNull();
76
+ expect(await engine.store.getDeliverable(deliverable.id)).toBeNull();
77
+ expect(await engine.store.getAttachment(projectFile.id)).toBeNull();
78
+ });
79
+
80
+ it('clears task references when containers, iterations and deliverables are deleted', async () => {
81
+ const { engine, project, other } = await setup();
82
+ const epic = await engine.createContainer({ projectId: project.id, name: 'Epic' });
83
+ const nested = await engine.createContainer({ projectId: project.id, name: 'Nested', parentId: epic.id });
84
+ const sprint = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
85
+ const cut = await engine.createDeliverable({ projectId: project.id, title: 'Cut' });
86
+ await engine.updateTask(other.id, { containerId: epic.id, iterationId: sprint.id, deliverableId: cut.id });
87
+
88
+ await engine.deleteContainer(epic.id);
89
+ await engine.deleteIteration(sprint.id);
90
+ await engine.deleteDeliverable(cut.id);
91
+
92
+ const task = await engine.getTask(other.id);
93
+ expect(task?.containerId).toBeUndefined();
94
+ expect(task?.iterationId).toBeUndefined();
95
+ expect(task?.deliverableId).toBeUndefined();
96
+ expect((await engine.getContainer(nested.id))?.parentId).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe('removeDependency', () => {
101
+ it('removes a dependency, publishes dependency.removed, and requires task.update', async () => {
102
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy() });
103
+ const project = await engine.createProject({
104
+ name: 'Deps',
105
+ members: [
106
+ { userId: 'cora', role: 'contributor' },
107
+ { userId: 'vic', role: 'viewer' }
108
+ ]
109
+ });
110
+ const a = await engine.createTask({ projectId: project.id, title: 'A' });
111
+ const b = await engine.createTask({ projectId: project.id, title: 'B' });
112
+ const dependency = await engine.addDependency({ taskId: a.id, dependsOnTaskId: b.id, type: 'blocking' });
113
+ const events: string[] = [];
114
+ engine.events.subscribe('dependency.removed', (e) => void events.push(e.name));
115
+
116
+ await expect(engine.withActor({ userId: 'vic' }).removeDependency(dependency.id)).rejects.toThrow(ForbiddenError);
117
+ expect(await engine.withActor({ userId: 'cora' }).removeDependency(dependency.id)).toBe(true);
118
+ expect(await engine.removeDependency(dependency.id)).toBe(false);
119
+ expect(events).toEqual(['dependency.removed']);
120
+ });
121
+ });
@@ -61,6 +61,7 @@ import {
61
61
  ProjectCreatedEvent,
62
62
  ProjectUpdatedEvent,
63
63
  ProjectDeletedEvent,
64
+ TaskDependencyRemovedEvent,
64
65
  WorkflowCreatedEvent,
65
66
  WorkflowUpdatedEvent,
66
67
  WorkflowDeletedEvent,
@@ -83,7 +84,7 @@ import {
83
84
  AttachmentDeletedEvent
84
85
  } from '../domain/events.js';
85
86
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
86
- import { validateCustomFieldValues } from '../domain/custom-fields.js';
87
+ import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
87
88
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
88
89
  import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
89
90
  import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
@@ -136,9 +137,9 @@ export class CriticalPathEngine {
136
137
  }
137
138
  }
138
139
 
139
- if (config.initialData) {
140
- this.ready = this.seedInitialData(config.initialData);
141
- }
140
+ this.ready = this.initialize(config);
141
+ // Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
142
+ this.ready.catch(() => {});
142
143
  }
143
144
 
144
145
  /**
@@ -264,6 +265,15 @@ export class CriticalPathEngine {
264
265
  return rest as T;
265
266
  }
266
267
 
268
+ private async initialize(config: CriticalPathConfig): Promise<void> {
269
+ if (config.initialData) {
270
+ await this.seedInitialData(config.initialData);
271
+ }
272
+ for (const plugin of this.plugins.getPlugins()) {
273
+ await plugin.init?.(this);
274
+ }
275
+ }
276
+
267
277
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
268
278
  if (data.workflows) {
269
279
  for (const wf of data.workflows) {
@@ -442,6 +452,7 @@ export class CriticalPathEngine {
442
452
 
443
453
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
444
454
  await this.requireWorkspaceAccess('project.create');
455
+ validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
445
456
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
446
457
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
447
458
  // The creator administers the project they create.
@@ -475,6 +486,7 @@ export class CriticalPathEngine {
475
486
  if (!existing) return null;
476
487
  await this.requireProjectAccess('project.update', id);
477
488
  if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
489
+ validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
478
490
  updates = this.withoutTenant(updates);
479
491
 
480
492
  const updated = await this.store.updateProject(id, updates);
@@ -512,12 +524,18 @@ export class CriticalPathEngine {
512
524
  const cascade = this.elevated();
513
525
 
514
526
  const tasks = await this.store.getTasks(id);
515
- const deletedTaskIds: string[] = [];
516
527
  for (const task of tasks) {
517
- if (await cascade.deleteTask(task.id)) {
518
- deletedTaskIds.push(task.id);
519
- }
528
+ // Subtasks may already be gone with their parent; deleteTask then returns false.
529
+ await cascade.deleteTask(task.id);
520
530
  }
531
+ const deletedTaskIds = (
532
+ await Promise.all(tasks.map(async (t) => ((await this.store.getTask(t.id)) ? null : t.id)))
533
+ ).filter((taskId): taskId is string => taskId !== null);
534
+
535
+ for (const container of await this.store.getContainers(id)) await this.store.deleteContainer(container.id);
536
+ for (const iteration of await this.store.getIterations(id)) await this.store.deleteIteration(iteration.id);
537
+ for (const deliverable of await this.store.getDeliverables(id)) await cascade.deleteDeliverable(deliverable.id);
538
+ for (const attachment of await this.store.getAttachments({ projectId: id })) await cascade.deleteAttachment(attachment.id);
521
539
 
522
540
  const deleted = await this.store.deleteProject(id);
523
541
  if (deleted) {
@@ -561,17 +579,19 @@ export class CriticalPathEngine {
561
579
  taskInput = { ...taskInput, reporterId: this.actor.userId };
562
580
  }
563
581
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
564
- const projectId = processedInput.projectId || taskInput.projectId;
582
+ if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
583
+ throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
584
+ }
585
+ const projectId = taskInput.projectId;
565
586
  const project = await this.store.getProject(projectId);
566
587
  const workflow = await this.resolveProjectWorkflow(projectId);
567
588
 
568
- // Validate custom field domain invariants
569
- if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
570
- validateCustomFieldValues(
571
- project.customFieldDefinitions,
572
- processedInput.customFields || taskInput.customFields
573
- );
574
- }
589
+ // Validate custom fields on the final (post-plugin) input, including required fields when none are given
590
+ validateCustomFieldValues(
591
+ project?.customFieldDefinitions,
592
+ processedInput.customFields ?? taskInput.customFields,
593
+ this.plugins.getCustomFieldTypes()
594
+ );
575
595
 
576
596
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
577
597
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
@@ -680,24 +700,32 @@ export class CriticalPathEngine {
680
700
  const project = await this.store.getProject(existing.projectId);
681
701
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
682
702
 
703
+ // Plugins run first; their output is then validated like caller input. Identity and
704
+ // ownership fields are never changed by hooks.
705
+ const {
706
+ id: _id,
707
+ projectId: _projectId,
708
+ createdAt: _createdAt,
709
+ ...processedUpdates
710
+ } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates) as Partial<Task>;
711
+
683
712
  // Validate workflow transition invariant
684
- if (taskUpdates.status && taskUpdates.status !== existing.status) {
685
- const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
713
+ if (processedUpdates.status && processedUpdates.status !== existing.status) {
714
+ const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
686
715
  if (!isValid) {
687
- throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
716
+ throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
688
717
  }
689
718
  }
690
719
 
691
720
  // Validate custom field invariants
692
- if (project?.customFieldDefinitions && taskUpdates.customFields) {
693
- validateCustomFieldValues(project.customFieldDefinitions, {
694
- ...existing.customFields,
695
- ...taskUpdates.customFields
696
- });
721
+ if (processedUpdates.customFields) {
722
+ validateCustomFieldValues(
723
+ project?.customFieldDefinitions,
724
+ { ...existing.customFields, ...processedUpdates.customFields },
725
+ this.plugins.getCustomFieldTypes()
726
+ );
697
727
  }
698
728
 
699
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
700
-
701
729
  const now = new Date().toISOString();
702
730
  const existingStatusDef = resolveStatusDefinition(
703
731
  existing.status,
@@ -1045,16 +1073,22 @@ export class CriticalPathEngine {
1045
1073
  }
1046
1074
  }
1047
1075
 
1048
- async deleteTask(id: string): Promise<boolean> {
1076
+ /**
1077
+ * Deletes a task and everything that belongs to it: subtasks (or, with `subtasks: 'detach'`,
1078
+ * their parent link), dependencies, comments, attachments (including stored files) and time
1079
+ * entries. The activity log is kept as an audit trail.
1080
+ */
1081
+ async deleteTask(id: string, options: { subtasks?: 'delete' | 'detach' } = {}): Promise<boolean> {
1049
1082
  const existing = await this.getTask(id);
1050
1083
  if (!existing) return false;
1051
1084
  await this.requireProjectAccess('task.delete', existing.projectId);
1052
1085
 
1053
- await this.plugins.runBeforeTaskDelete(id);
1086
+ await this.plugins.runBeforeTaskDelete(id, existing);
1087
+ await this.deleteTaskChildren(existing, options.subtasks ?? 'delete');
1054
1088
  const deleted = await this.store.deleteTask(id);
1055
1089
 
1056
1090
  if (deleted) {
1057
- await this.plugins.runAfterTaskDelete(id);
1091
+ await this.plugins.runAfterTaskDelete(id, existing);
1058
1092
  const now = new Date().toISOString();
1059
1093
 
1060
1094
  const event: TaskDeletedEvent = {
@@ -1082,6 +1116,65 @@ export class CriticalPathEngine {
1082
1116
  return deleted;
1083
1117
  }
1084
1118
 
1119
+ /** Removes records owned by a task; the task's deletion was already authorized. */
1120
+ private async deleteTaskChildren(task: Task, subtasks: 'delete' | 'detach'): Promise<void> {
1121
+ const cascade = this.elevated();
1122
+
1123
+ const children = (await this.store.getTasks(task.projectId)).filter((t) => t.parentId === task.id);
1124
+ for (const child of children) {
1125
+ if (subtasks === 'delete') {
1126
+ await cascade.deleteTask(child.id, { subtasks });
1127
+ } else {
1128
+ await this.store.updateTask(child.id, { parentId: undefined });
1129
+ }
1130
+ }
1131
+
1132
+ for (const dependency of await this.store.getDependencies(task.id)) {
1133
+ await cascade.removeDependency(dependency.id);
1134
+ }
1135
+
1136
+ const comments = await this.store.getComments(task.id);
1137
+ const attachments = [
1138
+ ...(await this.store.getAttachments({ taskId: task.id })),
1139
+ ...(await Promise.all(comments.map((c) => this.store.getAttachments({ commentId: c.id })))).flat()
1140
+ ];
1141
+ for (const attachmentId of new Set(attachments.map((a) => a.id))) {
1142
+ await cascade.deleteAttachment(attachmentId);
1143
+ }
1144
+ for (const comment of comments) {
1145
+ await cascade.deleteComment(comment.id);
1146
+ }
1147
+
1148
+ for (const entry of await this.store.getTimeEntries(task.id)) {
1149
+ await this.store.deleteTimeEntry(entry.id);
1150
+ }
1151
+ }
1152
+
1153
+ /** Removes a dependency between two tasks. Requires `task.update` on the dependent task's project. */
1154
+ async removeDependency(id: string): Promise<boolean> {
1155
+ const dependency = await this.store.getDependency(id);
1156
+ if (!dependency) return false;
1157
+ if (this.enforcing) {
1158
+ const task = await this.getTask(dependency.taskId);
1159
+ if (!task) return false;
1160
+ await this.requireProjectAccess('task.update', task.projectId);
1161
+ }
1162
+
1163
+ const removed = await this.store.removeDependency(id);
1164
+ if (removed) {
1165
+ const event: TaskDependencyRemovedEvent = {
1166
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1167
+ name: 'dependency.removed',
1168
+ aggregateId: id,
1169
+ aggregateType: 'Dependency',
1170
+ occurredAt: new Date().toISOString(),
1171
+ payload: { dependency }
1172
+ };
1173
+ await this.events.publish(event);
1174
+ }
1175
+ return removed;
1176
+ }
1177
+
1085
1178
  // --- Task Dependencies & Graph ---
1086
1179
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
1087
1180
  if (this.enforcing) {
@@ -1680,13 +1773,29 @@ export class CriticalPathEngine {
1680
1773
  return this.store.updateContainer(id, updates);
1681
1774
  }
1682
1775
 
1776
+ /** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
1683
1777
  async deleteContainer(id: string): Promise<boolean> {
1684
1778
  const existing = await this.getContainer(id);
1685
1779
  if (!existing) return false;
1686
1780
  await this.requireProjectAccess('plan.manage', existing.projectId);
1781
+ await this.clearTaskReferences(existing.projectId, 'containerId', id);
1782
+ for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
1783
+ await this.store.updateContainer(child.id, { parentId: undefined });
1784
+ }
1687
1785
  return this.store.deleteContainer(id);
1688
1786
  }
1689
1787
 
1788
+ /** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
1789
+ private async clearTaskReferences(
1790
+ projectId: string,
1791
+ field: 'containerId' | 'iterationId' | 'deliverableId',
1792
+ id: string
1793
+ ): Promise<void> {
1794
+ for (const task of await this.store.getTasks(projectId)) {
1795
+ if (task[field] === id) await this.store.updateTask(task.id, { [field]: undefined });
1796
+ }
1797
+ }
1798
+
1690
1799
  // --- Deliverables ---
1691
1800
  async getDeliverables(projectId: string): Promise<Deliverable[]> {
1692
1801
  if (!(await this.canReadProject(projectId))) return [];
@@ -1703,7 +1812,7 @@ export class CriticalPathEngine {
1703
1812
  await this.requireProjectAccess('plan.manage', input.projectId);
1704
1813
  const project = await this.store.getProject(input.projectId);
1705
1814
  if (project?.customFieldDefinitions && input.customFields) {
1706
- validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
1815
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
1707
1816
  }
1708
1817
 
1709
1818
  const created = await this.store.createDeliverable({
@@ -1786,6 +1895,7 @@ export class CriticalPathEngine {
1786
1895
  const existing = await this.getDeliverable(id);
1787
1896
  if (!existing) return false;
1788
1897
  await this.requireProjectAccess('plan.manage', existing.projectId);
1898
+ await this.clearTaskReferences(existing.projectId, 'deliverableId', id);
1789
1899
 
1790
1900
  const deleted = await this.store.deleteDeliverable(id);
1791
1901
  if (deleted) {
@@ -1920,10 +2030,12 @@ export class CriticalPathEngine {
1920
2030
  return updated;
1921
2031
  }
1922
2032
 
2033
+ /** Deletes an iteration. Its tasks are kept and moved back to the backlog (no iteration). */
1923
2034
  async deleteIteration(id: string): Promise<boolean> {
1924
2035
  const existing = await this.getIteration(id);
1925
2036
  if (!existing) return false;
1926
2037
  await this.requireProjectAccess('plan.manage', existing.projectId);
2038
+ await this.clearTaskReferences(existing.projectId, 'iterationId', id);
1927
2039
  return this.store.deleteIteration(id);
1928
2040
  }
1929
2041
 
@@ -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
- for (const plugin of this.plugins.values()) {
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
- for (const plugin of this.plugins.values()) {
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?.afterTaskDelete) {
64
- await plugin.hooks.afterTaskDelete(id);
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
  }