@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +57 -21
  3. package/dist/domain/custom-fields.d.ts +9 -0
  4. package/dist/domain/custom-fields.d.ts.map +1 -0
  5. package/dist/domain/custom-fields.js +72 -0
  6. package/dist/domain/custom-fields.js.map +1 -0
  7. package/dist/domain/ddd.test.d.ts +2 -0
  8. package/dist/domain/ddd.test.d.ts.map +1 -0
  9. package/dist/domain/ddd.test.js +246 -0
  10. package/dist/domain/ddd.test.js.map +1 -0
  11. package/dist/domain/entities.d.ts +82 -0
  12. package/dist/domain/entities.d.ts.map +1 -0
  13. package/dist/domain/entities.js +321 -0
  14. package/dist/domain/entities.js.map +1 -0
  15. package/dist/domain/events.d.ts +153 -0
  16. package/dist/domain/events.d.ts.map +1 -0
  17. package/dist/domain/events.js +35 -0
  18. package/dist/domain/events.js.map +1 -0
  19. package/dist/domain/graph.d.ts +19 -0
  20. package/dist/domain/graph.d.ts.map +1 -0
  21. package/dist/domain/graph.js +58 -0
  22. package/dist/domain/graph.js.map +1 -0
  23. package/dist/domain/index.d.ts +5 -0
  24. package/dist/domain/index.d.ts.map +1 -0
  25. package/dist/domain/index.js +5 -0
  26. package/dist/domain/index.js.map +1 -0
  27. package/dist/engine/index.d.ts +32 -2
  28. package/dist/engine/index.d.ts.map +1 -1
  29. package/dist/engine/index.js +416 -6
  30. package/dist/engine/index.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/storage/file-storage.d.ts +24 -0
  36. package/dist/storage/file-storage.d.ts.map +1 -0
  37. package/dist/storage/file-storage.js +73 -0
  38. package/dist/storage/file-storage.js.map +1 -0
  39. package/dist/storage/firebase-storage.d.ts +48 -0
  40. package/dist/storage/firebase-storage.d.ts.map +1 -0
  41. package/dist/storage/firebase-storage.js +142 -0
  42. package/dist/storage/firebase-storage.js.map +1 -0
  43. package/dist/storage/index.d.ts +4 -0
  44. package/dist/storage/index.d.ts.map +1 -0
  45. package/dist/storage/index.js +4 -0
  46. package/dist/storage/index.js.map +1 -0
  47. package/dist/storage/s3.d.ts +36 -0
  48. package/dist/storage/s3.d.ts.map +1 -0
  49. package/dist/storage/s3.js +135 -0
  50. package/dist/storage/s3.js.map +1 -0
  51. package/dist/storage/storage.test.d.ts +2 -0
  52. package/dist/storage/storage.test.d.ts.map +1 -0
  53. package/dist/storage/storage.test.js +117 -0
  54. package/dist/storage/storage.test.js.map +1 -0
  55. package/dist/store/firebase.d.ts +12 -1
  56. package/dist/store/firebase.d.ts.map +1 -1
  57. package/dist/store/firebase.js +63 -1
  58. package/dist/store/firebase.js.map +1 -1
  59. package/dist/store/index.d.ts +49 -2
  60. package/dist/store/index.d.ts.map +1 -1
  61. package/dist/store/index.js +49 -0
  62. package/dist/store/index.js.map +1 -1
  63. package/dist/store/sqlite.d.ts +12 -1
  64. package/dist/store/sqlite.d.ts.map +1 -1
  65. package/dist/store/sqlite.js +128 -6
  66. package/dist/store/sqlite.js.map +1 -1
  67. package/dist/store/sqlite.test.js +47 -1
  68. package/dist/store/sqlite.test.js.map +1 -1
  69. package/dist/types/index.d.ts +57 -1
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/domain/custom-fields.ts +131 -0
  73. package/src/domain/ddd.test.ts +307 -0
  74. package/src/domain/entities.ts +390 -0
  75. package/src/domain/events.ts +196 -0
  76. package/src/domain/graph.ts +70 -0
  77. package/src/domain/index.ts +4 -0
  78. package/src/engine/index.ts +518 -8
  79. package/src/index.ts +2 -0
  80. package/src/storage/file-storage.ts +96 -0
  81. package/src/storage/firebase-storage.ts +177 -0
  82. package/src/storage/index.ts +3 -0
  83. package/src/storage/s3.ts +165 -0
  84. package/src/storage/storage.test.ts +146 -0
  85. package/src/store/firebase.ts +64 -1
  86. package/src/store/index.ts +99 -11
  87. package/src/store/sqlite.test.ts +59 -1
  88. package/src/store/sqlite.ts +172 -6
  89. package/src/types/index.ts +70 -0
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -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: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
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: processedInput.status || taskInput.status || defaultStatus,
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: processedInput.actualStartDate ?? taskInput.actualStartDate,
204
- actualEndDate: processedInput.actualEndDate ?? taskInput.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
@@ -1,6 +1,8 @@
1
1
  export * from './types/index.js';
2
2
  export * from './utils/index.js';
3
3
  export * from './store/index.js';
4
+ export * from './storage/index.js';
4
5
  export * from './plugins/index.js';
5
6
  export * from './engine/index.js';
7
+ export * from './domain/index.js';
6
8