@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.
- package/CHANGELOG.md +11 -0
- package/README.md +35 -0
- package/dist/deliverables.test.d.ts +2 -0
- package/dist/deliverables.test.d.ts.map +1 -0
- package/dist/deliverables.test.js +232 -0
- package/dist/deliverables.test.js.map +1 -0
- package/dist/domain/entities.d.ts +25 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +153 -0
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/events.d.ts +31 -3
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +8 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +164 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/store/firebase.d.ts +6 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +41 -0
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +15 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +38 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +7 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +94 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +33 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/workflow.d.ts +1 -0
- package/dist/utils/workflow.d.ts.map +1 -1
- package/dist/utils/workflow.js +37 -0
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/deliverables.test.ts +284 -0
- package/src/domain/entities.ts +185 -2
- package/src/domain/events.ts +28 -3
- package/src/engine/index.ts +188 -2
- package/src/store/firebase.ts +47 -1
- package/src/store/index.ts +54 -1
- package/src/store/sqlite.ts +135 -4
- package/src/types/index.ts +51 -1
- package/src/utils/workflow.ts +38 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/domain/entities.ts
CHANGED
|
@@ -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);
|
package/src/domain/events.ts
CHANGED
|
@@ -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
|
|
package/src/engine/index.ts
CHANGED
|
@@ -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);
|
package/src/store/firebase.ts
CHANGED
|
@@ -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;
|
package/src/store/index.ts
CHANGED
|
@@ -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);
|