@critical-path/core 0.5.2 → 0.7.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 +40 -0
- package/README.md +57 -21
- package/dist/domain/custom-fields.d.ts +9 -0
- package/dist/domain/custom-fields.d.ts.map +1 -0
- package/dist/domain/custom-fields.js +72 -0
- package/dist/domain/custom-fields.js.map +1 -0
- package/dist/domain/ddd.test.d.ts +2 -0
- package/dist/domain/ddd.test.d.ts.map +1 -0
- package/dist/domain/ddd.test.js +246 -0
- package/dist/domain/ddd.test.js.map +1 -0
- package/dist/domain/entities.d.ts +82 -0
- package/dist/domain/entities.d.ts.map +1 -0
- package/dist/domain/entities.js +321 -0
- package/dist/domain/entities.js.map +1 -0
- package/dist/domain/events.d.ts +153 -0
- package/dist/domain/events.d.ts.map +1 -0
- package/dist/domain/events.js +35 -0
- package/dist/domain/events.js.map +1 -0
- package/dist/domain/graph.d.ts +19 -0
- package/dist/domain/graph.d.ts.map +1 -0
- package/dist/domain/graph.js +58 -0
- package/dist/domain/graph.js.map +1 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +5 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/engine/index.d.ts +32 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +416 -6
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/storage/file-storage.d.ts +24 -0
- package/dist/storage/file-storage.d.ts.map +1 -0
- package/dist/storage/file-storage.js +73 -0
- package/dist/storage/file-storage.js.map +1 -0
- package/dist/storage/firebase-storage.d.ts +48 -0
- package/dist/storage/firebase-storage.d.ts.map +1 -0
- package/dist/storage/firebase-storage.js +142 -0
- package/dist/storage/firebase-storage.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/s3.d.ts +36 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +135 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/storage.test.d.ts +2 -0
- package/dist/storage/storage.test.d.ts.map +1 -0
- package/dist/storage/storage.test.js +117 -0
- package/dist/storage/storage.test.js.map +1 -0
- package/dist/store/firebase.d.ts +12 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +63 -1
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +49 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +49 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +12 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +128 -6
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +47 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +57 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/custom-fields.ts +131 -0
- package/src/domain/ddd.test.ts +307 -0
- package/src/domain/entities.ts +390 -0
- package/src/domain/events.ts +196 -0
- package/src/domain/graph.ts +70 -0
- package/src/domain/index.ts +4 -0
- package/src/engine/index.ts +518 -8
- package/src/index.ts +2 -0
- package/src/storage/file-storage.ts +96 -0
- package/src/storage/firebase-storage.ts +177 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/s3.ts +165 -0
- package/src/storage/storage.test.ts +146 -0
- package/src/store/firebase.ts +64 -1
- package/src/store/index.ts +99 -11
- package/src/store/sqlite.test.ts +59 -1
- package/src/store/sqlite.ts +172 -6
- package/src/types/index.ts +70 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -2,33 +2,72 @@ import type {
|
|
|
2
2
|
CriticalPathConfig,
|
|
3
3
|
Project,
|
|
4
4
|
Task,
|
|
5
|
+
TaskDependency,
|
|
5
6
|
TaskDependencyGraph,
|
|
6
7
|
Team,
|
|
7
8
|
TaskContainer,
|
|
8
9
|
Iteration,
|
|
10
|
+
Comment,
|
|
11
|
+
Attachment,
|
|
12
|
+
FileStorageAdapter,
|
|
13
|
+
UploadFileInput,
|
|
14
|
+
PresignedUrlOptions,
|
|
15
|
+
PresignedUploadResult,
|
|
16
|
+
TimeEntry,
|
|
9
17
|
WebhookEvent,
|
|
10
|
-
Workflow
|
|
18
|
+
Workflow,
|
|
19
|
+
CreateTaskInput,
|
|
20
|
+
CreateProjectInput
|
|
11
21
|
} from '../types/index.js';
|
|
12
22
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
13
23
|
import { PluginRegistry } from '../plugins/index.js';
|
|
14
|
-
import { deriveTaskLifecycleState, type TaskLifecycleState } from '../utils/status.js';
|
|
24
|
+
import { deriveTaskLifecycleState, type TaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
|
|
15
25
|
import {
|
|
16
26
|
validateTransition,
|
|
17
27
|
getAllowedNextStatuses,
|
|
18
28
|
WorkflowValidationError,
|
|
19
29
|
DEFAULT_SOFTWARE_WORKFLOW
|
|
20
30
|
} from '../utils/workflow.js';
|
|
31
|
+
import {
|
|
32
|
+
DomainEventBus,
|
|
33
|
+
TaskCreatedEvent,
|
|
34
|
+
TaskUpdatedEvent,
|
|
35
|
+
TaskStatusChangedEvent,
|
|
36
|
+
TaskDeletedEvent,
|
|
37
|
+
ProjectCreatedEvent,
|
|
38
|
+
ProjectUpdatedEvent,
|
|
39
|
+
WorkflowCreatedEvent,
|
|
40
|
+
WorkflowUpdatedEvent,
|
|
41
|
+
WorkflowDeletedEvent,
|
|
42
|
+
IterationStartedEvent,
|
|
43
|
+
IterationCompletedEvent,
|
|
44
|
+
TeamCreatedEvent,
|
|
45
|
+
ContainerCreatedEvent,
|
|
46
|
+
TaskDependencyAddedEvent,
|
|
47
|
+
TimeLoggedEvent,
|
|
48
|
+
CommentAddedEvent,
|
|
49
|
+
CommentUpdatedEvent,
|
|
50
|
+
CommentDeletedEvent,
|
|
51
|
+
AttachmentCreatedEvent,
|
|
52
|
+
AttachmentDeletedEvent
|
|
53
|
+
} from '../domain/events.js';
|
|
54
|
+
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
55
|
+
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
21
56
|
|
|
22
57
|
export class CriticalPathEngine {
|
|
23
58
|
public readonly store: StorageAdapter;
|
|
59
|
+
public readonly fileStorage?: FileStorageAdapter;
|
|
24
60
|
public readonly plugins: PluginRegistry;
|
|
61
|
+
public readonly events: DomainEventBus;
|
|
25
62
|
|
|
26
63
|
constructor(config: CriticalPathConfig = {}) {
|
|
27
64
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
28
65
|
? (config.store as StorageAdapter)
|
|
29
66
|
: new InMemoryStore();
|
|
30
67
|
|
|
68
|
+
this.fileStorage = config.fileStorage;
|
|
31
69
|
this.plugins = new PluginRegistry();
|
|
70
|
+
this.events = new DomainEventBus();
|
|
32
71
|
|
|
33
72
|
if (config.plugins) {
|
|
34
73
|
for (const plugin of config.plugins) {
|
|
@@ -85,6 +124,18 @@ export class CriticalPathEngine {
|
|
|
85
124
|
|
|
86
125
|
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
87
126
|
const created = await this.store.createWorkflow(workflow);
|
|
127
|
+
const now = new Date().toISOString();
|
|
128
|
+
|
|
129
|
+
const event: WorkflowCreatedEvent = {
|
|
130
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
131
|
+
name: 'workflow.created',
|
|
132
|
+
aggregateId: created.id,
|
|
133
|
+
aggregateType: 'Workflow',
|
|
134
|
+
occurredAt: now,
|
|
135
|
+
payload: { workflow: created }
|
|
136
|
+
};
|
|
137
|
+
await this.events.publish(event);
|
|
138
|
+
|
|
88
139
|
await this.store.logActivity({
|
|
89
140
|
actorId: 'system',
|
|
90
141
|
action: 'workflow.created',
|
|
@@ -100,6 +151,17 @@ export class CriticalPathEngine {
|
|
|
100
151
|
|
|
101
152
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
102
153
|
if (updated) {
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
const event: WorkflowUpdatedEvent = {
|
|
156
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
157
|
+
name: 'workflow.updated',
|
|
158
|
+
aggregateId: updated.id,
|
|
159
|
+
aggregateType: 'Workflow',
|
|
160
|
+
occurredAt: now,
|
|
161
|
+
payload: { workflow: updated, previous: existing }
|
|
162
|
+
};
|
|
163
|
+
await this.events.publish(event);
|
|
164
|
+
|
|
103
165
|
await this.store.logActivity({
|
|
104
166
|
actorId: 'system',
|
|
105
167
|
action: 'workflow.updated',
|
|
@@ -116,6 +178,17 @@ export class CriticalPathEngine {
|
|
|
116
178
|
|
|
117
179
|
const deleted = await this.store.deleteWorkflow(id);
|
|
118
180
|
if (deleted) {
|
|
181
|
+
const now = new Date().toISOString();
|
|
182
|
+
const event: WorkflowDeletedEvent = {
|
|
183
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
184
|
+
name: 'workflow.deleted',
|
|
185
|
+
aggregateId: id,
|
|
186
|
+
aggregateType: 'Workflow',
|
|
187
|
+
occurredAt: now,
|
|
188
|
+
payload: { workflowId: id, name: existing.name }
|
|
189
|
+
};
|
|
190
|
+
await this.events.publish(event);
|
|
191
|
+
|
|
119
192
|
await this.store.logActivity({
|
|
120
193
|
actorId: 'system',
|
|
121
194
|
action: 'workflow.deleted',
|
|
@@ -160,6 +233,18 @@ export class CriticalPathEngine {
|
|
|
160
233
|
|
|
161
234
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
162
235
|
const created = await this.store.createProject(project);
|
|
236
|
+
const now = new Date().toISOString();
|
|
237
|
+
|
|
238
|
+
const event: ProjectCreatedEvent = {
|
|
239
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
240
|
+
name: 'project.created',
|
|
241
|
+
aggregateId: created.id,
|
|
242
|
+
aggregateType: 'Project',
|
|
243
|
+
occurredAt: now,
|
|
244
|
+
payload: { project: created }
|
|
245
|
+
};
|
|
246
|
+
await this.events.publish(event);
|
|
247
|
+
|
|
163
248
|
await this.store.logActivity({
|
|
164
249
|
projectId: created.id,
|
|
165
250
|
actorId: project.ownerId || 'system',
|
|
@@ -170,6 +255,34 @@ export class CriticalPathEngine {
|
|
|
170
255
|
return created;
|
|
171
256
|
}
|
|
172
257
|
|
|
258
|
+
async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
|
|
259
|
+
const existing = await this.store.getProject(id);
|
|
260
|
+
if (!existing) return null;
|
|
261
|
+
|
|
262
|
+
const updated = await this.store.updateProject(id, updates);
|
|
263
|
+
if (updated) {
|
|
264
|
+
const now = new Date().toISOString();
|
|
265
|
+
const event: ProjectUpdatedEvent = {
|
|
266
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
267
|
+
name: 'project.updated',
|
|
268
|
+
aggregateId: updated.id,
|
|
269
|
+
aggregateType: 'Project',
|
|
270
|
+
occurredAt: now,
|
|
271
|
+
payload: { project: updated, previous: existing }
|
|
272
|
+
};
|
|
273
|
+
await this.events.publish(event);
|
|
274
|
+
|
|
275
|
+
await this.store.logActivity({
|
|
276
|
+
projectId: updated.id,
|
|
277
|
+
actorId: 'system',
|
|
278
|
+
action: 'project.updated',
|
|
279
|
+
details: { name: updated.name }
|
|
280
|
+
});
|
|
281
|
+
this.dispatchWebhook('project.updated', { project: updated });
|
|
282
|
+
}
|
|
283
|
+
return updated;
|
|
284
|
+
}
|
|
285
|
+
|
|
173
286
|
// --- Tasks ---
|
|
174
287
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
175
288
|
return this.store.getTasks(projectId);
|
|
@@ -179,18 +292,34 @@ export class CriticalPathEngine {
|
|
|
179
292
|
return this.store.getTask(id);
|
|
180
293
|
}
|
|
181
294
|
|
|
182
|
-
async createTask(taskInput:
|
|
295
|
+
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
183
296
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
184
297
|
const projectId = processedInput.projectId || taskInput.projectId;
|
|
298
|
+
const project = await this.store.getProject(projectId);
|
|
185
299
|
const workflow = await this.resolveProjectWorkflow(projectId);
|
|
186
300
|
|
|
301
|
+
// Validate custom field domain invariants
|
|
302
|
+
if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
|
|
303
|
+
validateCustomFieldValues(
|
|
304
|
+
project.customFieldDefinitions,
|
|
305
|
+
processedInput.customFields || taskInput.customFields
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
187
309
|
const defaultStatus = workflow?.defaultStatusKey || 'todo';
|
|
310
|
+
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
311
|
+
const now = new Date().toISOString();
|
|
312
|
+
|
|
313
|
+
// Derive initial lifecycle timestamps
|
|
314
|
+
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
315
|
+
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.executionState === 'active' ? now : undefined);
|
|
316
|
+
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? (statusDef.completionState === 'done' ? now : undefined);
|
|
188
317
|
|
|
189
318
|
const created = await this.store.createTask({
|
|
190
319
|
projectId,
|
|
191
320
|
title: processedInput.title || taskInput.title,
|
|
192
321
|
description: processedInput.description ?? taskInput.description,
|
|
193
|
-
status:
|
|
322
|
+
status: initialStatus,
|
|
194
323
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
195
324
|
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
196
325
|
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
@@ -200,8 +329,8 @@ export class CriticalPathEngine {
|
|
|
200
329
|
teamId: processedInput.teamId ?? taskInput.teamId,
|
|
201
330
|
containerId: processedInput.containerId ?? taskInput.containerId,
|
|
202
331
|
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
203
|
-
actualStartDate
|
|
204
|
-
actualEndDate
|
|
332
|
+
actualStartDate,
|
|
333
|
+
actualEndDate,
|
|
205
334
|
dueDate: processedInput.dueDate ?? taskInput.dueDate,
|
|
206
335
|
estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
|
|
207
336
|
loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
|
|
@@ -210,7 +339,7 @@ export class CriticalPathEngine {
|
|
|
210
339
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
211
340
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
212
341
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
213
|
-
progress: processedInput.progress ?? taskInput.progress ?? 0,
|
|
342
|
+
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.completionState === 'done' ? 100 : 0),
|
|
214
343
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
215
344
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
216
345
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -218,6 +347,17 @@ export class CriticalPathEngine {
|
|
|
218
347
|
|
|
219
348
|
await this.plugins.runAfterTaskCreate(created);
|
|
220
349
|
|
|
350
|
+
// Publish typed Domain Event
|
|
351
|
+
const event: TaskCreatedEvent = {
|
|
352
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
353
|
+
name: 'task.created',
|
|
354
|
+
aggregateId: created.id,
|
|
355
|
+
aggregateType: 'Task',
|
|
356
|
+
occurredAt: now,
|
|
357
|
+
payload: { task: created }
|
|
358
|
+
};
|
|
359
|
+
await this.events.publish(event);
|
|
360
|
+
|
|
221
361
|
await this.store.logActivity({
|
|
222
362
|
projectId: created.projectId,
|
|
223
363
|
taskId: created.id,
|
|
@@ -234,21 +374,83 @@ export class CriticalPathEngine {
|
|
|
234
374
|
const existing = await this.store.getTask(id);
|
|
235
375
|
if (!existing) return null;
|
|
236
376
|
|
|
377
|
+
const project = await this.store.getProject(existing.projectId);
|
|
378
|
+
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
379
|
+
|
|
380
|
+
// Validate workflow transition invariant
|
|
237
381
|
if (updates.status && updates.status !== existing.status) {
|
|
238
|
-
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
239
382
|
const isValid = validateTransition(workflow || undefined, existing.status, updates.status);
|
|
240
383
|
if (!isValid) {
|
|
241
384
|
throw new WorkflowValidationError(existing.status, updates.status, workflow?.id);
|
|
242
385
|
}
|
|
243
386
|
}
|
|
244
387
|
|
|
388
|
+
// Validate custom field invariants
|
|
389
|
+
if (project?.customFieldDefinitions && updates.customFields) {
|
|
390
|
+
validateCustomFieldValues(project.customFieldDefinitions, {
|
|
391
|
+
...existing.customFields,
|
|
392
|
+
...updates.customFields
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
245
396
|
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, updates);
|
|
397
|
+
|
|
398
|
+
// Auto-update execution/completion timestamps if status changes
|
|
399
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
400
|
+
const statusDef = resolveStatusDefinition(
|
|
401
|
+
processedUpdates.status,
|
|
402
|
+
project?.statusDefinitions || workflow?.statuses
|
|
403
|
+
);
|
|
404
|
+
const now = new Date().toISOString();
|
|
405
|
+
if (statusDef.executionState === 'active' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
406
|
+
processedUpdates.actualStartDate = now;
|
|
407
|
+
}
|
|
408
|
+
if (statusDef.completionState === 'done' && !processedUpdates.actualEndDate) {
|
|
409
|
+
processedUpdates.actualEndDate = now;
|
|
410
|
+
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
411
|
+
processedUpdates.progress = 100;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
246
416
|
const updated = await this.store.updateTask(id, processedUpdates);
|
|
247
417
|
if (!updated) return null;
|
|
248
418
|
|
|
249
419
|
await this.plugins.runAfterTaskUpdate(updated, existing);
|
|
250
420
|
|
|
421
|
+
const now = new Date().toISOString();
|
|
251
422
|
const isStatusChange = existing.status !== updated.status;
|
|
423
|
+
|
|
424
|
+
// Publish typed Domain Events
|
|
425
|
+
if (isStatusChange) {
|
|
426
|
+
const statusEvent: TaskStatusChangedEvent = {
|
|
427
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
428
|
+
name: 'task.status_changed',
|
|
429
|
+
aggregateId: updated.id,
|
|
430
|
+
aggregateType: 'Task',
|
|
431
|
+
occurredAt: now,
|
|
432
|
+
payload: {
|
|
433
|
+
task: updated,
|
|
434
|
+
previousStatus: existing.status,
|
|
435
|
+
newStatus: updated.status
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
await this.events.publish(statusEvent);
|
|
439
|
+
} else {
|
|
440
|
+
const updateEvent: TaskUpdatedEvent = {
|
|
441
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
442
|
+
name: 'task.updated',
|
|
443
|
+
aggregateId: updated.id,
|
|
444
|
+
aggregateType: 'Task',
|
|
445
|
+
occurredAt: now,
|
|
446
|
+
payload: {
|
|
447
|
+
task: updated,
|
|
448
|
+
previous: existing
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
await this.events.publish(updateEvent);
|
|
452
|
+
}
|
|
453
|
+
|
|
252
454
|
await this.store.logActivity({
|
|
253
455
|
projectId: updated.projectId,
|
|
254
456
|
taskId: updated.id,
|
|
@@ -274,6 +476,22 @@ export class CriticalPathEngine {
|
|
|
274
476
|
|
|
275
477
|
if (deleted) {
|
|
276
478
|
await this.plugins.runAfterTaskDelete(id);
|
|
479
|
+
const now = new Date().toISOString();
|
|
480
|
+
|
|
481
|
+
const event: TaskDeletedEvent = {
|
|
482
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
483
|
+
name: 'task.deleted',
|
|
484
|
+
aggregateId: id,
|
|
485
|
+
aggregateType: 'Task',
|
|
486
|
+
occurredAt: now,
|
|
487
|
+
payload: {
|
|
488
|
+
taskId: id,
|
|
489
|
+
projectId: existing.projectId,
|
|
490
|
+
title: existing.title
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
await this.events.publish(event);
|
|
494
|
+
|
|
277
495
|
await this.store.logActivity({
|
|
278
496
|
projectId: existing.projectId,
|
|
279
497
|
taskId: id,
|
|
@@ -287,6 +505,35 @@ export class CriticalPathEngine {
|
|
|
287
505
|
}
|
|
288
506
|
|
|
289
507
|
// --- Task Dependencies & Graph ---
|
|
508
|
+
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
509
|
+
const existingDependencies = await this.store.getDependencies(dep.taskId);
|
|
510
|
+
const allProjectDeps = [
|
|
511
|
+
...existingDependencies,
|
|
512
|
+
...(await this.store.getDependencies(dep.dependsOnTaskId))
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
516
|
+
const cycleCheck = detectDependencyCycle(allProjectDeps, dep);
|
|
517
|
+
if (cycleCheck.hasCycle) {
|
|
518
|
+
throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const created = await this.store.addDependency(dep);
|
|
522
|
+
const now = new Date().toISOString();
|
|
523
|
+
|
|
524
|
+
const event: TaskDependencyAddedEvent = {
|
|
525
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
526
|
+
name: 'dependency.added',
|
|
527
|
+
aggregateId: created.id,
|
|
528
|
+
aggregateType: 'Dependency',
|
|
529
|
+
occurredAt: now,
|
|
530
|
+
payload: { dependency: created }
|
|
531
|
+
};
|
|
532
|
+
await this.events.publish(event);
|
|
533
|
+
|
|
534
|
+
return created;
|
|
535
|
+
}
|
|
536
|
+
|
|
290
537
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
291
538
|
const dependencies = await this.store.getDependencies(taskId);
|
|
292
539
|
const upstreamTaskIds = new Set<string>();
|
|
@@ -325,6 +572,216 @@ export class CriticalPathEngine {
|
|
|
325
572
|
return deriveTaskLifecycleState(task, statusDefs);
|
|
326
573
|
}
|
|
327
574
|
|
|
575
|
+
// --- Time Tracking ---
|
|
576
|
+
async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string }): Promise<TimeEntry> {
|
|
577
|
+
if (entry.hours <= 0) {
|
|
578
|
+
throw new Error('Logged hours must be a positive number.');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const task = await this.store.getTask(entry.taskId);
|
|
582
|
+
if (task) {
|
|
583
|
+
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
584
|
+
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
585
|
+
const newBillableHours = entry.isBillable !== false ? (task.billableHours || 0) + entry.hours : task.billableHours;
|
|
586
|
+
await this.store.updateTask(task.id, {
|
|
587
|
+
loggedHours: newLoggedHours,
|
|
588
|
+
actualHours: newActualHours,
|
|
589
|
+
billableHours: newBillableHours
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> = {
|
|
594
|
+
...entry,
|
|
595
|
+
userId: entry.userId || task?.assigneeId || 'system'
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const created = await this.store.logTime(fullEntry);
|
|
599
|
+
const now = new Date().toISOString();
|
|
600
|
+
|
|
601
|
+
const event: TimeLoggedEvent = {
|
|
602
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
603
|
+
name: 'time.logged',
|
|
604
|
+
aggregateId: entry.taskId,
|
|
605
|
+
aggregateType: 'Task',
|
|
606
|
+
occurredAt: now,
|
|
607
|
+
payload: { taskId: entry.taskId, timeEntry: created }
|
|
608
|
+
};
|
|
609
|
+
await this.events.publish(event);
|
|
610
|
+
|
|
611
|
+
return created;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- Comments ---
|
|
615
|
+
async getComments(taskId: string): Promise<Comment[]> {
|
|
616
|
+
return this.store.getComments(taskId);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async getComment(id: string): Promise<Comment | null> {
|
|
620
|
+
return this.store.getComment(id);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
624
|
+
const created = await this.store.addComment(comment);
|
|
625
|
+
const now = new Date().toISOString();
|
|
626
|
+
|
|
627
|
+
const event: CommentAddedEvent = {
|
|
628
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
629
|
+
name: 'comment.created',
|
|
630
|
+
aggregateId: created.id,
|
|
631
|
+
aggregateType: 'Comment',
|
|
632
|
+
occurredAt: now,
|
|
633
|
+
payload: { taskId: comment.taskId, comment: created }
|
|
634
|
+
};
|
|
635
|
+
await this.events.publish(event);
|
|
636
|
+
|
|
637
|
+
this.dispatchWebhook('comment.created', { comment: created });
|
|
638
|
+
return created;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
642
|
+
const existing = await this.store.getComment(id);
|
|
643
|
+
if (!existing) return null;
|
|
644
|
+
|
|
645
|
+
const updated = await this.store.updateComment(id, updates);
|
|
646
|
+
if (!updated) return null;
|
|
647
|
+
|
|
648
|
+
const now = new Date().toISOString();
|
|
649
|
+
const event: CommentUpdatedEvent = {
|
|
650
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
651
|
+
name: 'comment.updated',
|
|
652
|
+
aggregateId: updated.id,
|
|
653
|
+
aggregateType: 'Comment',
|
|
654
|
+
occurredAt: now,
|
|
655
|
+
payload: { comment: updated, previous: existing }
|
|
656
|
+
};
|
|
657
|
+
await this.events.publish(event);
|
|
658
|
+
|
|
659
|
+
this.dispatchWebhook('comment.updated', { comment: updated });
|
|
660
|
+
return updated;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async deleteComment(id: string): Promise<boolean> {
|
|
664
|
+
const existing = await this.store.getComment(id);
|
|
665
|
+
if (!existing) return false;
|
|
666
|
+
|
|
667
|
+
const deleted = await this.store.deleteComment(id);
|
|
668
|
+
if (deleted) {
|
|
669
|
+
const now = new Date().toISOString();
|
|
670
|
+
const event: CommentDeletedEvent = {
|
|
671
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
672
|
+
name: 'comment.deleted',
|
|
673
|
+
aggregateId: id,
|
|
674
|
+
aggregateType: 'Comment',
|
|
675
|
+
occurredAt: now,
|
|
676
|
+
payload: { taskId: existing.taskId, commentId: id }
|
|
677
|
+
};
|
|
678
|
+
await this.events.publish(event);
|
|
679
|
+
|
|
680
|
+
this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
|
|
681
|
+
}
|
|
682
|
+
return deleted;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// --- Attachments ---
|
|
686
|
+
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
|
|
687
|
+
return this.store.getAttachments(filter);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async getAttachment(id: string): Promise<Attachment | null> {
|
|
691
|
+
return this.store.getAttachment(id);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
|
|
695
|
+
const created = await this.store.createAttachment(attachment);
|
|
696
|
+
const now = new Date().toISOString();
|
|
697
|
+
|
|
698
|
+
const event: AttachmentCreatedEvent = {
|
|
699
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
700
|
+
name: 'attachment.created',
|
|
701
|
+
aggregateId: created.id,
|
|
702
|
+
aggregateType: 'Attachment',
|
|
703
|
+
occurredAt: now,
|
|
704
|
+
payload: { attachment: created }
|
|
705
|
+
};
|
|
706
|
+
await this.events.publish(event);
|
|
707
|
+
|
|
708
|
+
this.dispatchWebhook('attachment.created', { attachment: created });
|
|
709
|
+
return created;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async deleteAttachment(id: string): Promise<boolean> {
|
|
713
|
+
const existing = await this.store.getAttachment(id);
|
|
714
|
+
if (!existing) return false;
|
|
715
|
+
|
|
716
|
+
if (existing.storageKey && this.fileStorage) {
|
|
717
|
+
try {
|
|
718
|
+
await this.fileStorage.delete(existing.storageKey);
|
|
719
|
+
} catch {
|
|
720
|
+
// Silently continue if underlying file is already gone
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const deleted = await this.store.deleteAttachment(id);
|
|
725
|
+
if (deleted) {
|
|
726
|
+
const now = new Date().toISOString();
|
|
727
|
+
const event: AttachmentDeletedEvent = {
|
|
728
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
729
|
+
name: 'attachment.deleted',
|
|
730
|
+
aggregateId: id,
|
|
731
|
+
aggregateType: 'Attachment',
|
|
732
|
+
occurredAt: now,
|
|
733
|
+
payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
|
|
734
|
+
};
|
|
735
|
+
await this.events.publish(event);
|
|
736
|
+
|
|
737
|
+
this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
|
|
738
|
+
}
|
|
739
|
+
return deleted;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async uploadAttachmentFile(
|
|
743
|
+
input: UploadFileInput & {
|
|
744
|
+
taskId?: string;
|
|
745
|
+
projectId?: string;
|
|
746
|
+
commentId?: string;
|
|
747
|
+
uploaderId: string;
|
|
748
|
+
uploaderType?: 'user' | 'agent' | 'system';
|
|
749
|
+
metadata?: Record<string, unknown>;
|
|
750
|
+
}
|
|
751
|
+
): Promise<Attachment> {
|
|
752
|
+
if (!this.fileStorage) {
|
|
753
|
+
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const uploadResult = await this.fileStorage.upload({
|
|
757
|
+
filename: input.filename,
|
|
758
|
+
data: input.data,
|
|
759
|
+
mimeType: input.mimeType,
|
|
760
|
+
pathPrefix: input.pathPrefix || (input.projectId ? `projects/${input.projectId}` : input.taskId ? `tasks/${input.taskId}` : undefined)
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
return this.createAttachment({
|
|
764
|
+
taskId: input.taskId,
|
|
765
|
+
projectId: input.projectId,
|
|
766
|
+
commentId: input.commentId,
|
|
767
|
+
uploaderId: input.uploaderId,
|
|
768
|
+
uploaderType: input.uploaderType || 'user',
|
|
769
|
+
filename: input.filename,
|
|
770
|
+
mimeType: uploadResult.mimeType,
|
|
771
|
+
sizeBytes: uploadResult.sizeBytes,
|
|
772
|
+
url: uploadResult.url,
|
|
773
|
+
storageKey: uploadResult.storageKey,
|
|
774
|
+
metadata: input.metadata
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async getPresignedAttachmentUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
779
|
+
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
780
|
+
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
781
|
+
}
|
|
782
|
+
return this.fileStorage.getPresignedUploadUrl(options);
|
|
783
|
+
}
|
|
784
|
+
|
|
328
785
|
// --- Teams ---
|
|
329
786
|
async getTeams(): Promise<Team[]> {
|
|
330
787
|
return this.store.getTeams();
|
|
@@ -336,6 +793,18 @@ export class CriticalPathEngine {
|
|
|
336
793
|
|
|
337
794
|
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
338
795
|
const created = await this.store.createTeam(team);
|
|
796
|
+
const now = new Date().toISOString();
|
|
797
|
+
|
|
798
|
+
const event: TeamCreatedEvent = {
|
|
799
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
800
|
+
name: 'team.created',
|
|
801
|
+
aggregateId: created.id,
|
|
802
|
+
aggregateType: 'Team',
|
|
803
|
+
occurredAt: now,
|
|
804
|
+
payload: { team: created }
|
|
805
|
+
};
|
|
806
|
+
await this.events.publish(event);
|
|
807
|
+
|
|
339
808
|
this.dispatchWebhook('team.created', { team: created });
|
|
340
809
|
return created;
|
|
341
810
|
}
|
|
@@ -359,6 +828,18 @@ export class CriticalPathEngine {
|
|
|
359
828
|
|
|
360
829
|
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
361
830
|
const created = await this.store.createContainer(container);
|
|
831
|
+
const now = new Date().toISOString();
|
|
832
|
+
|
|
833
|
+
const event: ContainerCreatedEvent = {
|
|
834
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
835
|
+
name: 'container.created',
|
|
836
|
+
aggregateId: created.id,
|
|
837
|
+
aggregateType: 'Container',
|
|
838
|
+
occurredAt: now,
|
|
839
|
+
payload: { container: created }
|
|
840
|
+
};
|
|
841
|
+
await this.events.publish(event);
|
|
842
|
+
|
|
362
843
|
this.dispatchWebhook('container.created', { container: created });
|
|
363
844
|
return created;
|
|
364
845
|
}
|
|
@@ -383,6 +864,16 @@ export class CriticalPathEngine {
|
|
|
383
864
|
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
384
865
|
const created = await this.store.createIteration(iteration);
|
|
385
866
|
if (created.status === 'active') {
|
|
867
|
+
const now = new Date().toISOString();
|
|
868
|
+
const event: IterationStartedEvent = {
|
|
869
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
870
|
+
name: 'iteration.started',
|
|
871
|
+
aggregateId: created.id,
|
|
872
|
+
aggregateType: 'Iteration',
|
|
873
|
+
occurredAt: now,
|
|
874
|
+
payload: { iteration: created }
|
|
875
|
+
};
|
|
876
|
+
await this.events.publish(event);
|
|
386
877
|
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
387
878
|
}
|
|
388
879
|
return created;
|
|
@@ -394,9 +885,28 @@ export class CriticalPathEngine {
|
|
|
394
885
|
|
|
395
886
|
const updated = await this.store.updateIteration(id, updates);
|
|
396
887
|
if (updated) {
|
|
888
|
+
const now = new Date().toISOString();
|
|
397
889
|
if (existing.status !== 'active' && updated.status === 'active') {
|
|
890
|
+
const event: IterationStartedEvent = {
|
|
891
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
892
|
+
name: 'iteration.started',
|
|
893
|
+
aggregateId: updated.id,
|
|
894
|
+
aggregateType: 'Iteration',
|
|
895
|
+
occurredAt: now,
|
|
896
|
+
payload: { iteration: updated }
|
|
897
|
+
};
|
|
898
|
+
await this.events.publish(event);
|
|
398
899
|
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
399
900
|
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
901
|
+
const event: IterationCompletedEvent = {
|
|
902
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
903
|
+
name: 'iteration.completed',
|
|
904
|
+
aggregateId: updated.id,
|
|
905
|
+
aggregateType: 'Iteration',
|
|
906
|
+
occurredAt: now,
|
|
907
|
+
payload: { iteration: updated }
|
|
908
|
+
};
|
|
909
|
+
await this.events.publish(event);
|
|
400
910
|
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
401
911
|
}
|
|
402
912
|
}
|
package/src/index.ts
CHANGED