@critical-path/core 0.11.0 → 0.13.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +39 -0
  3. package/dist/deliverables.test.d.ts +2 -0
  4. package/dist/deliverables.test.d.ts.map +1 -0
  5. package/dist/deliverables.test.js +232 -0
  6. package/dist/deliverables.test.js.map +1 -0
  7. package/dist/domain/entities.d.ts +25 -1
  8. package/dist/domain/entities.d.ts.map +1 -1
  9. package/dist/domain/entities.js +153 -0
  10. package/dist/domain/entities.js.map +1 -1
  11. package/dist/domain/events.d.ts +31 -3
  12. package/dist/domain/events.d.ts.map +1 -1
  13. package/dist/domain/events.js.map +1 -1
  14. package/dist/engine/index.d.ts +9 -1
  15. package/dist/engine/index.d.ts.map +1 -1
  16. package/dist/engine/index.js +173 -2
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/index.test.js +28 -0
  19. package/dist/index.test.js.map +1 -1
  20. package/dist/store/firebase.d.ts +6 -1
  21. package/dist/store/firebase.d.ts.map +1 -1
  22. package/dist/store/firebase.js +41 -0
  23. package/dist/store/firebase.js.map +1 -1
  24. package/dist/store/index.d.ts +15 -2
  25. package/dist/store/index.d.ts.map +1 -1
  26. package/dist/store/index.js +38 -0
  27. package/dist/store/index.js.map +1 -1
  28. package/dist/store/sqlite.d.ts +7 -1
  29. package/dist/store/sqlite.d.ts.map +1 -1
  30. package/dist/store/sqlite.js +103 -6
  31. package/dist/store/sqlite.js.map +1 -1
  32. package/dist/types/index.d.ts +41 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/utils/workflow.d.ts +2 -0
  35. package/dist/utils/workflow.d.ts.map +1 -1
  36. package/dist/utils/workflow.js +64 -0
  37. package/dist/utils/workflow.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/deliverables.test.ts +284 -0
  40. package/src/domain/entities.ts +185 -2
  41. package/src/domain/events.ts +28 -3
  42. package/src/engine/index.ts +197 -2
  43. package/src/index.test.ts +33 -0
  44. package/src/store/firebase.ts +47 -1
  45. package/src/store/index.ts +54 -1
  46. package/src/store/sqlite.ts +146 -5
  47. package/src/types/index.ts +60 -1
  48. package/src/utils/workflow.ts +77 -0
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -9,14 +9,15 @@ import type {
9
9
  TimeEntry,
10
10
  Comment,
11
11
  CommentReaction,
12
- Attachment
12
+ Attachment,
13
+ Deliverable
13
14
  } from '../types/index.js';
14
15
 
15
16
  export interface DomainEvent<TPayload = Record<string, unknown>> {
16
17
  readonly id: string;
17
18
  readonly name: string;
18
19
  readonly aggregateId: string;
19
- readonly aggregateType: 'Task' | 'Project' | 'Workflow' | 'Iteration' | 'Team' | 'Container' | 'Dependency' | 'Comment' | 'Attachment';
20
+ readonly aggregateType: 'Task' | 'Project' | 'Workflow' | 'Iteration' | 'Team' | 'Container' | 'Dependency' | 'Comment' | 'Attachment' | 'Deliverable';
20
21
  readonly occurredAt: string;
21
22
  readonly payload: TPayload;
22
23
  }
@@ -136,6 +137,26 @@ export interface ContainerCreatedEvent extends DomainEvent<{ container: TaskCont
136
137
  readonly aggregateType: 'Container';
137
138
  }
138
139
 
140
+ export interface DeliverableCreatedEvent extends DomainEvent<{ deliverable: Deliverable }> {
141
+ readonly name: 'deliverable.created';
142
+ readonly aggregateType: 'Deliverable';
143
+ }
144
+
145
+ export interface DeliverableUpdatedEvent extends DomainEvent<{ deliverable: Deliverable; previous: Deliverable }> {
146
+ readonly name: 'deliverable.updated';
147
+ readonly aggregateType: 'Deliverable';
148
+ }
149
+
150
+ export interface DeliverableStatusChangedEvent extends DomainEvent<{ deliverable: Deliverable; previousStatus: string; newStatus: string }> {
151
+ readonly name: 'deliverable.status_changed';
152
+ readonly aggregateType: 'Deliverable';
153
+ }
154
+
155
+ export interface DeliverableDeletedEvent extends DomainEvent<{ deliverableId: string; projectId: string }> {
156
+ readonly name: 'deliverable.deleted';
157
+ readonly aggregateType: 'Deliverable';
158
+ }
159
+
139
160
  export type CriticalPathDomainEvent =
140
161
  | TaskCreatedEvent
141
162
  | TaskUpdatedEvent
@@ -158,7 +179,11 @@ export type CriticalPathDomainEvent =
158
179
  | IterationStartedEvent
159
180
  | IterationCompletedEvent
160
181
  | TeamCreatedEvent
161
- | ContainerCreatedEvent;
182
+ | ContainerCreatedEvent
183
+ | DeliverableCreatedEvent
184
+ | DeliverableUpdatedEvent
185
+ | DeliverableStatusChangedEvent
186
+ | DeliverableDeletedEvent;
162
187
 
163
188
  export type DomainEventHandler<T extends DomainEvent = DomainEvent> = (event: T) => Promise<void> | void;
164
189
 
@@ -17,7 +17,10 @@ import type {
17
17
  WebhookEvent,
18
18
  Workflow,
19
19
  CreateTaskInput,
20
- CreateProjectInput
20
+ CreateProjectInput,
21
+ Deliverable,
22
+ DeliverableSummary,
23
+ CreateDeliverableInput
21
24
  } from '../types/index.js';
22
25
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
23
26
  import { PluginRegistry } from '../plugins/index.js';
@@ -25,6 +28,7 @@ import { deriveTaskLifecycleState, type TaskLifecycleState, resolveStatusDefinit
25
28
  import {
26
29
  validateTransition,
27
30
  getAllowedNextStatuses,
31
+ getAllowedPreviousStatuses,
28
32
  WorkflowValidationError,
29
33
  DEFAULT_SOFTWARE_WORKFLOW
30
34
  } from '../utils/workflow.js';
@@ -44,6 +48,10 @@ import {
44
48
  IterationCompletedEvent,
45
49
  TeamCreatedEvent,
46
50
  ContainerCreatedEvent,
51
+ DeliverableCreatedEvent,
52
+ DeliverableUpdatedEvent,
53
+ DeliverableStatusChangedEvent,
54
+ DeliverableDeletedEvent,
47
55
  TaskDependencyAddedEvent,
48
56
  TimeLoggedEvent,
49
57
  CommentAddedEvent,
@@ -63,6 +71,7 @@ export class CriticalPathEngine {
63
71
  public readonly fileStorage?: FileStorageAdapter;
64
72
  public readonly plugins: PluginRegistry;
65
73
  public readonly events: DomainEventBus;
74
+ public readonly ready: Promise<void> = Promise.resolve();
66
75
 
67
76
  constructor(config: CriticalPathConfig = {}) {
68
77
  this.store = typeof config.store === 'object' && config.store !== null
@@ -80,7 +89,7 @@ export class CriticalPathEngine {
80
89
  }
81
90
 
82
91
  if (config.initialData) {
83
- this.seedInitialData(config.initialData);
92
+ this.ready = this.seedInitialData(config.initialData);
84
93
  }
85
94
  }
86
95
 
@@ -105,6 +114,11 @@ export class CriticalPathEngine {
105
114
  await this.store.createContainer(c);
106
115
  }
107
116
  }
117
+ if (data.deliverables) {
118
+ for (const d of data.deliverables) {
119
+ await this.store.createDeliverable(d);
120
+ }
121
+ }
108
122
  if (data.iterations) {
109
123
  for (const it of data.iterations) {
110
124
  await this.store.createIteration(it);
@@ -226,6 +240,13 @@ export class CriticalPathEngine {
226
240
  return getAllowedNextStatuses(workflow || undefined, task.status);
227
241
  }
228
242
 
243
+ async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
244
+ const task = await this.store.getTask(taskId);
245
+ if (!task) return [];
246
+ const workflow = await this.resolveProjectWorkflow(task.projectId);
247
+ return getAllowedPreviousStatuses(workflow || undefined, task.status);
248
+ }
249
+
229
250
  // --- Projects ---
230
251
  async getProjects(): Promise<Project[]> {
231
252
  return this.store.getProjects();
@@ -327,11 +348,13 @@ export class CriticalPathEngine {
327
348
  priority: processedInput.priority || taskInput.priority || 'medium',
328
349
  taskType: processedInput.taskType || taskInput.taskType || 'task',
329
350
  assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
351
+ assignees: processedInput.assignees ?? taskInput.assignees,
330
352
  reporterId: processedInput.reporterId ?? taskInput.reporterId,
331
353
  reviewerId: processedInput.reviewerId ?? taskInput.reviewerId,
332
354
  iterationId: processedInput.iterationId ?? taskInput.iterationId,
333
355
  teamId: processedInput.teamId ?? taskInput.teamId,
334
356
  containerId: processedInput.containerId ?? taskInput.containerId,
357
+ deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
335
358
  plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
336
359
  actualStartDate,
337
360
  actualEndDate,
@@ -937,6 +960,178 @@ export class CriticalPathEngine {
937
960
  return this.store.deleteContainer(id);
938
961
  }
939
962
 
963
+ // --- Deliverables ---
964
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
965
+ return this.store.getDeliverables(projectId);
966
+ }
967
+
968
+ async getDeliverable(id: string): Promise<Deliverable | null> {
969
+ return this.store.getDeliverable(id);
970
+ }
971
+
972
+ async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
973
+ const project = await this.store.getProject(input.projectId);
974
+ if (project?.customFieldDefinitions && input.customFields) {
975
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
976
+ }
977
+
978
+ const created = await this.store.createDeliverable({
979
+ ...input,
980
+ status: input.status || 'planned',
981
+ outputUrls: input.outputUrls ?? [],
982
+ customFields: input.customFields ?? {}
983
+ });
984
+ const now = new Date().toISOString();
985
+
986
+ const event: DeliverableCreatedEvent = {
987
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
988
+ name: 'deliverable.created',
989
+ aggregateId: created.id,
990
+ aggregateType: 'Deliverable',
991
+ occurredAt: now,
992
+ payload: { deliverable: created }
993
+ };
994
+ await this.events.publish(event);
995
+
996
+ await this.store.logActivity({
997
+ projectId: created.projectId,
998
+ actorId: 'system',
999
+ action: 'deliverable.created',
1000
+ details: { title: created.title, format: created.format }
1001
+ });
1002
+ this.dispatchWebhook('deliverable.created', { deliverable: created });
1003
+ return created;
1004
+ }
1005
+
1006
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
1007
+ const existing = await this.store.getDeliverable(id);
1008
+ if (!existing) return null;
1009
+
1010
+ if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1011
+ updates.deliveredAt = new Date().toISOString();
1012
+ }
1013
+
1014
+ const updated = await this.store.updateDeliverable(id, updates);
1015
+ if (updated) {
1016
+ const now = new Date().toISOString();
1017
+
1018
+ if (updates.status && updates.status !== existing.status) {
1019
+ const statusEvent: DeliverableStatusChangedEvent = {
1020
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1021
+ name: 'deliverable.status_changed',
1022
+ aggregateId: updated.id,
1023
+ aggregateType: 'Deliverable',
1024
+ occurredAt: now,
1025
+ payload: {
1026
+ deliverable: updated,
1027
+ previousStatus: existing.status,
1028
+ newStatus: updated.status
1029
+ }
1030
+ };
1031
+ await this.events.publish(statusEvent);
1032
+ this.dispatchWebhook('deliverable.status_changed', {
1033
+ deliverable: updated,
1034
+ previousStatus: existing.status,
1035
+ newStatus: updated.status
1036
+ });
1037
+ }
1038
+
1039
+ const updateEvent: DeliverableUpdatedEvent = {
1040
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1041
+ name: 'deliverable.updated',
1042
+ aggregateId: updated.id,
1043
+ aggregateType: 'Deliverable',
1044
+ occurredAt: now,
1045
+ payload: { deliverable: updated, previous: existing }
1046
+ };
1047
+ await this.events.publish(updateEvent);
1048
+
1049
+ await this.store.logActivity({
1050
+ projectId: updated.projectId,
1051
+ actorId: 'system',
1052
+ action: 'deliverable.updated',
1053
+ details: { title: updated.title, status: updated.status }
1054
+ });
1055
+ this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1056
+ }
1057
+ return updated;
1058
+ }
1059
+
1060
+ async deleteDeliverable(id: string): Promise<boolean> {
1061
+ const existing = await this.store.getDeliverable(id);
1062
+ if (!existing) return false;
1063
+
1064
+ const deleted = await this.store.deleteDeliverable(id);
1065
+ if (deleted) {
1066
+ const now = new Date().toISOString();
1067
+ const event: DeliverableDeletedEvent = {
1068
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1069
+ name: 'deliverable.deleted',
1070
+ aggregateId: id,
1071
+ aggregateType: 'Deliverable',
1072
+ occurredAt: now,
1073
+ payload: { deliverableId: id, projectId: existing.projectId }
1074
+ };
1075
+ await this.events.publish(event);
1076
+
1077
+ await this.store.logActivity({
1078
+ projectId: existing.projectId,
1079
+ actorId: 'system',
1080
+ action: 'deliverable.deleted',
1081
+ details: { title: existing.title }
1082
+ });
1083
+ this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1084
+ }
1085
+ return deleted;
1086
+ }
1087
+
1088
+ async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1089
+ const deliverable = await this.store.getDeliverable(deliverableId);
1090
+ if (!deliverable) return null;
1091
+
1092
+ const project = await this.store.getProject(deliverable.projectId);
1093
+ const workflow = await this.resolveProjectWorkflow(deliverable.projectId);
1094
+ const allTasks = await this.store.getTasks(deliverable.projectId);
1095
+ const tasks = allTasks.filter((t) => t.deliverableId === deliverableId);
1096
+
1097
+ const totalTasks = tasks.length;
1098
+ let completedTasks = 0;
1099
+ let activeTasks = 0;
1100
+ let estimatedHours = 0;
1101
+ let loggedHours = 0;
1102
+ let totalProgress = 0;
1103
+
1104
+ for (const task of tasks) {
1105
+ const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1106
+ if (statusDef.completionState === 'done') {
1107
+ completedTasks++;
1108
+ } else if (statusDef.executionState === 'active') {
1109
+ activeTasks++;
1110
+ }
1111
+
1112
+ if (task.estimatedHours) estimatedHours += task.estimatedHours;
1113
+ if (task.loggedHours) loggedHours += task.loggedHours;
1114
+
1115
+ if (typeof task.progress === 'number') {
1116
+ totalProgress += task.progress;
1117
+ } else {
1118
+ totalProgress += statusDef.completionState === 'done' ? 100 : 0;
1119
+ }
1120
+ }
1121
+
1122
+ const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
1123
+
1124
+ return {
1125
+ deliverable,
1126
+ totalTasks,
1127
+ completedTasks,
1128
+ activeTasks,
1129
+ progressPercentage,
1130
+ estimatedHours,
1131
+ loggedHours
1132
+ };
1133
+ }
1134
+
940
1135
  // --- Iterations ---
941
1136
  async getIterations(projectId: string): Promise<Iteration[]> {
942
1137
  return this.store.getIterations(projectId);
package/src/index.test.ts CHANGED
@@ -339,6 +339,39 @@ describe('CriticalPathEngine Core Tests', () => {
339
339
  });
340
340
  expect(updated?.mentions).toEqual(['coordinator']);
341
341
  });
342
+
343
+ it('supports multi-assignees and backward status transitions', async () => {
344
+ const engine = new CriticalPathEngine();
345
+ const proj = await engine.createProject({ key: 'TRN', name: 'Transitions Project' });
346
+
347
+ // Task with multi-assignees
348
+ const task = await engine.createTask({
349
+ projectId: proj.id,
350
+ title: 'Multi-assignee Task',
351
+ status: 'in_progress',
352
+ assignees: [
353
+ { id: 'agent_1', name: 'Agent Alpha', role: 'Planner', type: 'agent' },
354
+ { id: 'user_1', name: 'Alice Developer', role: 'Developer', type: 'user', avatarUrl: 'https://example.com/alice.png' }
355
+ ]
356
+ });
357
+
358
+ expect(task.assignees).toHaveLength(2);
359
+ expect(task.assignees?.[0].type).toBe('agent');
360
+ expect(task.assignees?.[1].avatarUrl).toBe('https://example.com/alice.png');
361
+
362
+ // Backward transitions
363
+ const previousStatuses = await engine.getAllowedPreviousTaskTransitions(task.id);
364
+ expect(previousStatuses).toContain('todo');
365
+
366
+ // First status (backlog) should have no previous status
367
+ const backlogTask = await engine.createTask({
368
+ projectId: proj.id,
369
+ title: 'Backlog Task',
370
+ status: 'backlog'
371
+ });
372
+ const backlogPrev = await engine.getAllowedPreviousTaskTransitions(backlogTask.id);
373
+ expect(backlogPrev).toEqual([]);
374
+ });
342
375
  });
343
376
 
344
377
 
@@ -11,7 +11,9 @@ import type {
11
11
  Activity,
12
12
  Webhook,
13
13
  TaskDependency,
14
- Workflow
14
+ Workflow,
15
+ Deliverable,
16
+ CreateDeliverableInput
15
17
  } from '../types/index.js';
16
18
  import { generateProjectKey } from '../utils/key.js';
17
19
 
@@ -283,6 +285,50 @@ export class FirebaseStore implements StorageAdapter {
283
285
  return true;
284
286
  }
285
287
 
288
+ // --- Deliverables ---
289
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
290
+ const snap = await this.db.collection('deliverables').where('projectId', '==', projectId).get();
291
+ return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
292
+ }
293
+
294
+ async getDeliverable(id: string): Promise<Deliverable | null> {
295
+ const snap = await this.db.collection('deliverables').doc(id).get();
296
+ if (!snap.exists) return null;
297
+ return { ...snap.data(), id: snap.id };
298
+ }
299
+
300
+ async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
301
+ const docRef = this.db.collection('deliverables').doc();
302
+ const now = new Date().toISOString();
303
+ const newDeliverable: Deliverable = {
304
+ ...deliverable,
305
+ id: docRef.id,
306
+ status: deliverable.status || 'planned',
307
+ outputUrls: deliverable.outputUrls ? [...deliverable.outputUrls] : [],
308
+ customFields: deliverable.customFields ? { ...deliverable.customFields } : {},
309
+ createdAt: now,
310
+ updatedAt: now
311
+ };
312
+ await docRef.set(sanitizeFirestoreData(newDeliverable));
313
+ return newDeliverable;
314
+ }
315
+
316
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
317
+ const existing = await this.getDeliverable(id);
318
+ if (!existing) return null;
319
+
320
+ const updated: Deliverable = { ...existing, ...updates, updatedAt: new Date().toISOString() };
321
+ await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
322
+ return updated;
323
+ }
324
+
325
+ async deleteDeliverable(id: string): Promise<boolean> {
326
+ const existing = await this.getDeliverable(id);
327
+ if (!existing) return false;
328
+ await this.db.collection('deliverables').doc(id).delete();
329
+ return true;
330
+ }
331
+
286
332
  // --- Tasks ---
287
333
  async getTasks(projectId?: string): Promise<Task[]> {
288
334
  let snap;
@@ -13,7 +13,9 @@ import type {
13
13
  Activity,
14
14
  Webhook,
15
15
  TaskDependency,
16
- Workflow
16
+ Workflow,
17
+ Deliverable,
18
+ CreateDeliverableInput
17
19
  } from '../types/index.js';
18
20
  import { generateProjectKey } from '../utils/key.js';
19
21
 
@@ -102,12 +104,21 @@ export interface WebhookRepository {
102
104
  addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
103
105
  }
104
106
 
107
+ export interface DeliverableRepository {
108
+ getDeliverables(projectId: string): Promise<Deliverable[]>;
109
+ getDeliverable(id: string): Promise<Deliverable | null>;
110
+ createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable>;
111
+ updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null>;
112
+ deleteDeliverable(id: string): Promise<boolean>;
113
+ }
114
+
105
115
  export interface StorageAdapter
106
116
  extends ProjectRepository,
107
117
  WorkflowRepository,
108
118
  TaskRepository,
109
119
  TeamRepository,
110
120
  ContainerRepository,
121
+ DeliverableRepository,
111
122
  IterationRepository,
112
123
  CommentRepository,
113
124
  AttachmentRepository,
@@ -122,6 +133,7 @@ export class InMemoryStore implements StorageAdapter {
122
133
  private tasks = new Map<string, Task>();
123
134
  private teams = new Map<string, Team>();
124
135
  private containers = new Map<string, TaskContainer>();
136
+ private deliverables = new Map<string, Deliverable>();
125
137
  private iterations = new Map<string, Iteration>();
126
138
  private comments = new Map<string, Comment>();
127
139
  private attachments = new Map<string, Attachment>();
@@ -306,6 +318,47 @@ export class InMemoryStore implements StorageAdapter {
306
318
  return this.containers.delete(id);
307
319
  }
308
320
 
321
+ // Deliverables
322
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
323
+ return Array.from(this.deliverables.values()).filter((d) => d.projectId === projectId);
324
+ }
325
+
326
+ async getDeliverable(id: string): Promise<Deliverable | null> {
327
+ return this.deliverables.get(id) || null;
328
+ }
329
+
330
+ async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
331
+ const id = (deliverable as any).id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
332
+ const now = new Date().toISOString();
333
+ const newDeliverable: Deliverable = {
334
+ ...deliverable,
335
+ id,
336
+ status: deliverable.status || 'planned',
337
+ outputUrls: deliverable.outputUrls ? [...deliverable.outputUrls] : [],
338
+ customFields: deliverable.customFields ? { ...deliverable.customFields } : {},
339
+ createdAt: now,
340
+ updatedAt: now
341
+ };
342
+ this.deliverables.set(id, newDeliverable);
343
+ return newDeliverable;
344
+ }
345
+
346
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
347
+ const existing = this.deliverables.get(id);
348
+ if (!existing) return null;
349
+ const updated: Deliverable = {
350
+ ...existing,
351
+ ...updates,
352
+ updatedAt: new Date().toISOString()
353
+ };
354
+ this.deliverables.set(id, updated);
355
+ return updated;
356
+ }
357
+
358
+ async deleteDeliverable(id: string): Promise<boolean> {
359
+ return this.deliverables.delete(id);
360
+ }
361
+
309
362
  // Iterations
310
363
  async getIterations(projectId: string): Promise<Iteration[]> {
311
364
  return Array.from(this.iterations.values()).filter((s) => s.projectId === projectId);