@critical-path/core 0.11.0 → 0.12.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 (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +35 -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 +8 -1
  15. package/dist/engine/index.d.ts.map +1 -1
  16. package/dist/engine/index.js +164 -1
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/store/firebase.d.ts +6 -1
  19. package/dist/store/firebase.d.ts.map +1 -1
  20. package/dist/store/firebase.js +41 -0
  21. package/dist/store/firebase.js.map +1 -1
  22. package/dist/store/index.d.ts +15 -2
  23. package/dist/store/index.d.ts.map +1 -1
  24. package/dist/store/index.js +38 -0
  25. package/dist/store/index.js.map +1 -1
  26. package/dist/store/sqlite.d.ts +7 -1
  27. package/dist/store/sqlite.d.ts.map +1 -1
  28. package/dist/store/sqlite.js +94 -5
  29. package/dist/store/sqlite.js.map +1 -1
  30. package/dist/types/index.d.ts +33 -1
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/dist/utils/workflow.d.ts +1 -0
  33. package/dist/utils/workflow.d.ts.map +1 -1
  34. package/dist/utils/workflow.js +37 -0
  35. package/dist/utils/workflow.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/deliverables.test.ts +284 -0
  38. package/src/domain/entities.ts +185 -2
  39. package/src/domain/events.ts +28 -3
  40. package/src/engine/index.ts +188 -2
  41. package/src/store/firebase.ts +47 -1
  42. package/src/store/index.ts +54 -1
  43. package/src/store/sqlite.ts +135 -4
  44. package/src/types/index.ts +51 -1
  45. package/src/utils/workflow.ts +38 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -8,7 +8,10 @@ import type {
8
8
  TaskTodoItem,
9
9
  CustomFieldDefinition,
10
10
  CreateTaskInput,
11
- CreateProjectInput
11
+ CreateProjectInput,
12
+ Deliverable,
13
+ DeliverableStatus,
14
+ CreateDeliverableInput
12
15
  } from '../types/index.js';
13
16
  import type {
14
17
  DomainEvent,
@@ -16,7 +19,10 @@ import type {
16
19
  TaskStatusChangedEvent,
17
20
  TaskUpdatedEvent,
18
21
  TimeLoggedEvent,
19
- ProjectCreatedEvent
22
+ ProjectCreatedEvent,
23
+ DeliverableCreatedEvent,
24
+ DeliverableUpdatedEvent,
25
+ DeliverableStatusChangedEvent
20
26
  } from './events.js';
21
27
  import { validateTransition, WorkflowValidationError } from '../utils/workflow.js';
22
28
  import { resolveStatusDefinition } from '../utils/status.js';
@@ -67,6 +73,7 @@ export class TaskEntity extends BaseEntity {
67
73
  public iterationId?: string;
68
74
  public teamId?: string;
69
75
  public containerId?: string;
76
+ public deliverableId?: string;
70
77
  public plannedStartDate?: string;
71
78
  public actualStartDate?: string;
72
79
  public actualEndDate?: string;
@@ -99,6 +106,7 @@ export class TaskEntity extends BaseEntity {
99
106
  this.iterationId = data.iterationId;
100
107
  this.teamId = data.teamId;
101
108
  this.containerId = data.containerId;
109
+ this.deliverableId = data.deliverableId;
102
110
  this.plannedStartDate = data.plannedStartDate;
103
111
  this.actualStartDate = data.actualStartDate;
104
112
  this.actualEndDate = data.actualEndDate;
@@ -365,6 +373,7 @@ export class TaskEntity extends BaseEntity {
365
373
  iterationId: this.iterationId,
366
374
  teamId: this.teamId,
367
375
  containerId: this.containerId,
376
+ deliverableId: this.deliverableId,
368
377
  plannedStartDate: this.plannedStartDate,
369
378
  actualStartDate: this.actualStartDate,
370
379
  actualEndDate: this.actualEndDate,
@@ -471,6 +480,180 @@ export class ProjectEntity extends BaseEntity {
471
480
  }
472
481
  }
473
482
 
483
+ export class DeliverableEntity extends BaseEntity {
484
+ public projectId: string;
485
+ public title: string;
486
+ public description?: string;
487
+ public status: DeliverableStatus;
488
+ public format?: string;
489
+ public specs?: Record<string, unknown>;
490
+ public leadId?: string;
491
+ public reviewerId?: string;
492
+ public dueDate?: string;
493
+ public deliveredAt?: string;
494
+ public outputUrls?: string[];
495
+ public customFields?: Record<string, unknown>;
496
+
497
+ constructor(data: Deliverable) {
498
+ super(data.id, data.createdAt, data.updatedAt);
499
+ this.projectId = data.projectId;
500
+ this.title = data.title;
501
+ this.description = data.description;
502
+ this.status = data.status;
503
+ this.format = data.format;
504
+ this.specs = data.specs ? { ...data.specs } : undefined;
505
+ this.leadId = data.leadId;
506
+ this.reviewerId = data.reviewerId;
507
+ this.dueDate = data.dueDate;
508
+ this.deliveredAt = data.deliveredAt;
509
+ this.outputUrls = data.outputUrls ? [...data.outputUrls] : [];
510
+ this.customFields = data.customFields ? { ...data.customFields } : {};
511
+ }
512
+
513
+ public static create(
514
+ input: CreateDeliverableInput,
515
+ options?: { id?: string; customFieldDefs?: CustomFieldDefinition[] }
516
+ ): DeliverableEntity {
517
+ const id = options?.id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
518
+ const now = new Date().toISOString();
519
+
520
+ if (options?.customFieldDefs) {
521
+ validateCustomFieldValues(options.customFieldDefs, input.customFields);
522
+ }
523
+
524
+ const deliverable = new DeliverableEntity({
525
+ ...input,
526
+ id,
527
+ createdAt: now,
528
+ updatedAt: now,
529
+ status: input.status || 'planned',
530
+ outputUrls: input.outputUrls ?? [],
531
+ customFields: input.customFields ?? {}
532
+ });
533
+
534
+ const createdEvent: DeliverableCreatedEvent = {
535
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
536
+ name: 'deliverable.created',
537
+ aggregateId: deliverable.id,
538
+ aggregateType: 'Deliverable',
539
+ occurredAt: now,
540
+ payload: { deliverable: deliverable.toPlain() }
541
+ };
542
+ deliverable.raiseEvent(createdEvent);
543
+
544
+ return deliverable;
545
+ }
546
+
547
+ public transitionTo(newStatus: DeliverableStatus): this {
548
+ if (this.status === newStatus) return this;
549
+
550
+ const previousStatus = this.status;
551
+ this.status = newStatus;
552
+ const now = new Date().toISOString();
553
+
554
+ if (newStatus === 'delivered' && !this.deliveredAt) {
555
+ this.deliveredAt = now;
556
+ }
557
+
558
+ this.markUpdated();
559
+
560
+ const event: DeliverableStatusChangedEvent = {
561
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
562
+ name: 'deliverable.status_changed',
563
+ aggregateId: this.id,
564
+ aggregateType: 'Deliverable',
565
+ occurredAt: now,
566
+ payload: {
567
+ deliverable: this.toPlain(),
568
+ previousStatus,
569
+ newStatus
570
+ }
571
+ };
572
+ this.raiseEvent(event);
573
+
574
+ return this;
575
+ }
576
+
577
+ public addOutputUrl(url: string): this {
578
+ if (!this.outputUrls) {
579
+ this.outputUrls = [];
580
+ }
581
+ if (!this.outputUrls.includes(url)) {
582
+ const previous = this.toPlain();
583
+ this.outputUrls.push(url);
584
+ this.markUpdated();
585
+
586
+ const event: DeliverableUpdatedEvent = {
587
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
588
+ name: 'deliverable.updated',
589
+ aggregateId: this.id,
590
+ aggregateType: 'Deliverable',
591
+ occurredAt: new Date().toISOString(),
592
+ payload: {
593
+ deliverable: this.toPlain(),
594
+ previous
595
+ }
596
+ };
597
+ this.raiseEvent(event);
598
+ }
599
+ return this;
600
+ }
601
+
602
+ public update(updates: Partial<Deliverable>, customFieldDefs?: CustomFieldDefinition[]): this {
603
+ const previous = this.toPlain();
604
+
605
+ if (updates.customFields && customFieldDefs) {
606
+ validateCustomFieldValues(customFieldDefs, {
607
+ ...this.customFields,
608
+ ...updates.customFields
609
+ });
610
+ }
611
+
612
+ if (updates.status && updates.status !== this.status) {
613
+ this.transitionTo(updates.status);
614
+ delete updates.status;
615
+ }
616
+
617
+ Object.assign(this, updates);
618
+ this.markUpdated();
619
+
620
+ const event: DeliverableUpdatedEvent = {
621
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
622
+ name: 'deliverable.updated',
623
+ aggregateId: this.id,
624
+ aggregateType: 'Deliverable',
625
+ occurredAt: new Date().toISOString(),
626
+ payload: {
627
+ deliverable: this.toPlain(),
628
+ previous
629
+ }
630
+ };
631
+ this.raiseEvent(event);
632
+
633
+ return this;
634
+ }
635
+
636
+ public toPlain(): Deliverable {
637
+ return {
638
+ id: this.id,
639
+ projectId: this.projectId,
640
+ title: this.title,
641
+ description: this.description,
642
+ status: this.status,
643
+ format: this.format,
644
+ specs: this.specs ? { ...this.specs } : undefined,
645
+ leadId: this.leadId,
646
+ reviewerId: this.reviewerId,
647
+ dueDate: this.dueDate,
648
+ deliveredAt: this.deliveredAt,
649
+ outputUrls: this.outputUrls ? [...this.outputUrls] : [],
650
+ customFields: this.customFields ? { ...this.customFields } : {},
651
+ createdAt: this.createdAt,
652
+ updatedAt: this.updatedAt
653
+ };
654
+ }
655
+ }
656
+
474
657
  export class AttachmentValidationError extends Error {
475
658
  constructor(message: string) {
476
659
  super(message);
@@ -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';
@@ -44,6 +47,10 @@ import {
44
47
  IterationCompletedEvent,
45
48
  TeamCreatedEvent,
46
49
  ContainerCreatedEvent,
50
+ DeliverableCreatedEvent,
51
+ DeliverableUpdatedEvent,
52
+ DeliverableStatusChangedEvent,
53
+ DeliverableDeletedEvent,
47
54
  TaskDependencyAddedEvent,
48
55
  TimeLoggedEvent,
49
56
  CommentAddedEvent,
@@ -63,6 +70,7 @@ export class CriticalPathEngine {
63
70
  public readonly fileStorage?: FileStorageAdapter;
64
71
  public readonly plugins: PluginRegistry;
65
72
  public readonly events: DomainEventBus;
73
+ public readonly ready: Promise<void> = Promise.resolve();
66
74
 
67
75
  constructor(config: CriticalPathConfig = {}) {
68
76
  this.store = typeof config.store === 'object' && config.store !== null
@@ -80,7 +88,7 @@ export class CriticalPathEngine {
80
88
  }
81
89
 
82
90
  if (config.initialData) {
83
- this.seedInitialData(config.initialData);
91
+ this.ready = this.seedInitialData(config.initialData);
84
92
  }
85
93
  }
86
94
 
@@ -105,6 +113,11 @@ export class CriticalPathEngine {
105
113
  await this.store.createContainer(c);
106
114
  }
107
115
  }
116
+ if (data.deliverables) {
117
+ for (const d of data.deliverables) {
118
+ await this.store.createDeliverable(d);
119
+ }
120
+ }
108
121
  if (data.iterations) {
109
122
  for (const it of data.iterations) {
110
123
  await this.store.createIteration(it);
@@ -332,6 +345,7 @@ export class CriticalPathEngine {
332
345
  iterationId: processedInput.iterationId ?? taskInput.iterationId,
333
346
  teamId: processedInput.teamId ?? taskInput.teamId,
334
347
  containerId: processedInput.containerId ?? taskInput.containerId,
348
+ deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
335
349
  plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
336
350
  actualStartDate,
337
351
  actualEndDate,
@@ -937,6 +951,178 @@ export class CriticalPathEngine {
937
951
  return this.store.deleteContainer(id);
938
952
  }
939
953
 
954
+ // --- Deliverables ---
955
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
956
+ return this.store.getDeliverables(projectId);
957
+ }
958
+
959
+ async getDeliverable(id: string): Promise<Deliverable | null> {
960
+ return this.store.getDeliverable(id);
961
+ }
962
+
963
+ async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
964
+ const project = await this.store.getProject(input.projectId);
965
+ if (project?.customFieldDefinitions && input.customFields) {
966
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
967
+ }
968
+
969
+ const created = await this.store.createDeliverable({
970
+ ...input,
971
+ status: input.status || 'planned',
972
+ outputUrls: input.outputUrls ?? [],
973
+ customFields: input.customFields ?? {}
974
+ });
975
+ const now = new Date().toISOString();
976
+
977
+ const event: DeliverableCreatedEvent = {
978
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
979
+ name: 'deliverable.created',
980
+ aggregateId: created.id,
981
+ aggregateType: 'Deliverable',
982
+ occurredAt: now,
983
+ payload: { deliverable: created }
984
+ };
985
+ await this.events.publish(event);
986
+
987
+ await this.store.logActivity({
988
+ projectId: created.projectId,
989
+ actorId: 'system',
990
+ action: 'deliverable.created',
991
+ details: { title: created.title, format: created.format }
992
+ });
993
+ this.dispatchWebhook('deliverable.created', { deliverable: created });
994
+ return created;
995
+ }
996
+
997
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
998
+ const existing = await this.store.getDeliverable(id);
999
+ if (!existing) return null;
1000
+
1001
+ if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1002
+ updates.deliveredAt = new Date().toISOString();
1003
+ }
1004
+
1005
+ const updated = await this.store.updateDeliverable(id, updates);
1006
+ if (updated) {
1007
+ const now = new Date().toISOString();
1008
+
1009
+ if (updates.status && updates.status !== existing.status) {
1010
+ const statusEvent: DeliverableStatusChangedEvent = {
1011
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1012
+ name: 'deliverable.status_changed',
1013
+ aggregateId: updated.id,
1014
+ aggregateType: 'Deliverable',
1015
+ occurredAt: now,
1016
+ payload: {
1017
+ deliverable: updated,
1018
+ previousStatus: existing.status,
1019
+ newStatus: updated.status
1020
+ }
1021
+ };
1022
+ await this.events.publish(statusEvent);
1023
+ this.dispatchWebhook('deliverable.status_changed', {
1024
+ deliverable: updated,
1025
+ previousStatus: existing.status,
1026
+ newStatus: updated.status
1027
+ });
1028
+ }
1029
+
1030
+ const updateEvent: DeliverableUpdatedEvent = {
1031
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1032
+ name: 'deliverable.updated',
1033
+ aggregateId: updated.id,
1034
+ aggregateType: 'Deliverable',
1035
+ occurredAt: now,
1036
+ payload: { deliverable: updated, previous: existing }
1037
+ };
1038
+ await this.events.publish(updateEvent);
1039
+
1040
+ await this.store.logActivity({
1041
+ projectId: updated.projectId,
1042
+ actorId: 'system',
1043
+ action: 'deliverable.updated',
1044
+ details: { title: updated.title, status: updated.status }
1045
+ });
1046
+ this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1047
+ }
1048
+ return updated;
1049
+ }
1050
+
1051
+ async deleteDeliverable(id: string): Promise<boolean> {
1052
+ const existing = await this.store.getDeliverable(id);
1053
+ if (!existing) return false;
1054
+
1055
+ const deleted = await this.store.deleteDeliverable(id);
1056
+ if (deleted) {
1057
+ const now = new Date().toISOString();
1058
+ const event: DeliverableDeletedEvent = {
1059
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1060
+ name: 'deliverable.deleted',
1061
+ aggregateId: id,
1062
+ aggregateType: 'Deliverable',
1063
+ occurredAt: now,
1064
+ payload: { deliverableId: id, projectId: existing.projectId }
1065
+ };
1066
+ await this.events.publish(event);
1067
+
1068
+ await this.store.logActivity({
1069
+ projectId: existing.projectId,
1070
+ actorId: 'system',
1071
+ action: 'deliverable.deleted',
1072
+ details: { title: existing.title }
1073
+ });
1074
+ this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1075
+ }
1076
+ return deleted;
1077
+ }
1078
+
1079
+ async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1080
+ const deliverable = await this.store.getDeliverable(deliverableId);
1081
+ if (!deliverable) return null;
1082
+
1083
+ const project = await this.store.getProject(deliverable.projectId);
1084
+ const workflow = await this.resolveProjectWorkflow(deliverable.projectId);
1085
+ const allTasks = await this.store.getTasks(deliverable.projectId);
1086
+ const tasks = allTasks.filter((t) => t.deliverableId === deliverableId);
1087
+
1088
+ const totalTasks = tasks.length;
1089
+ let completedTasks = 0;
1090
+ let activeTasks = 0;
1091
+ let estimatedHours = 0;
1092
+ let loggedHours = 0;
1093
+ let totalProgress = 0;
1094
+
1095
+ for (const task of tasks) {
1096
+ const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1097
+ if (statusDef.completionState === 'done') {
1098
+ completedTasks++;
1099
+ } else if (statusDef.executionState === 'active') {
1100
+ activeTasks++;
1101
+ }
1102
+
1103
+ if (task.estimatedHours) estimatedHours += task.estimatedHours;
1104
+ if (task.loggedHours) loggedHours += task.loggedHours;
1105
+
1106
+ if (typeof task.progress === 'number') {
1107
+ totalProgress += task.progress;
1108
+ } else {
1109
+ totalProgress += statusDef.completionState === 'done' ? 100 : 0;
1110
+ }
1111
+ }
1112
+
1113
+ const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
1114
+
1115
+ return {
1116
+ deliverable,
1117
+ totalTasks,
1118
+ completedTasks,
1119
+ activeTasks,
1120
+ progressPercentage,
1121
+ estimatedHours,
1122
+ loggedHours
1123
+ };
1124
+ }
1125
+
940
1126
  // --- Iterations ---
941
1127
  async getIterations(projectId: string): Promise<Iteration[]> {
942
1128
  return this.store.getIterations(projectId);
@@ -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);