@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -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 +8 -2
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +1 -0
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/cascade.test.d.ts +2 -0
- package/dist/engine/cascade.test.d.ts.map +1 -0
- package/dist/engine/cascade.test.js +99 -0
- package/dist/engine/cascade.test.js.map +1 -0
- package/dist/engine/index.d.ts +17 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +120 -27
- package/dist/engine/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 +96 -93
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/store/firebase.d.ts +8 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +30 -8
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +7 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +3 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +41 -24
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +51 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/webhooks.test.js +2 -1
- package/dist/webhooks/webhooks.test.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/custom-fields.ts +43 -2
- package/src/domain/events.ts +7 -0
- package/src/engine/cascade.test.ts +121 -0
- package/src/engine/index.ts +142 -30
- package/src/plugins/index.ts +51 -16
- package/src/plugins/plugins.test.ts +161 -0
- package/src/schemas/index.ts +2 -1
- package/src/store/firebase.ts +33 -8
- package/src/store/index.ts +16 -0
- package/src/store/sqlite.ts +56 -31
- package/src/types/index.ts +57 -5
- package/src/webhooks/webhooks.test.ts +2 -1
- 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
|
}
|
package/src/domain/events.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 (
|
|
685
|
-
const isValid = validateTransition(workflow || undefined, existing.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,
|
|
716
|
+
throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
|
|
688
717
|
}
|
|
689
718
|
}
|
|
690
719
|
|
|
691
720
|
// Validate custom field invariants
|
|
692
|
-
if (
|
|
693
|
-
validateCustomFieldValues(
|
|
694
|
-
|
|
695
|
-
...
|
|
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
|
-
|
|
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
|
|
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
|
}
|