@critical-path/core 0.10.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 +23 -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 +170 -3
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +25 -0
- package/dist/index.test.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 +107 -9
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +6 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +34 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/mentions.d.ts +24 -0
- package/dist/utils/mentions.d.ts.map +1 -0
- package/dist/utils/mentions.js +73 -0
- package/dist/utils/mentions.js.map +1 -0
- package/dist/utils/mentions.test.d.ts +2 -0
- package/dist/utils/mentions.test.d.ts.map +1 -0
- package/dist/utils/mentions.test.js +71 -0
- package/dist/utils/mentions.test.js.map +1 -0
- 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 +195 -4
- package/src/index.test.ts +30 -0
- package/src/store/firebase.ts +47 -1
- package/src/store/index.ts +54 -1
- package/src/store/sqlite.test.ts +6 -1
- package/src/store/sqlite.ts +148 -6
- package/src/types/index.ts +52 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/mentions.test.ts +82 -0
- package/src/utils/mentions.ts +89 -0
- 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';
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
WorkflowValidationError,
|
|
29
32
|
DEFAULT_SOFTWARE_WORKFLOW
|
|
30
33
|
} from '../utils/workflow.js';
|
|
34
|
+
import { extractMentions } from '../utils/mentions.js';
|
|
31
35
|
import {
|
|
32
36
|
DomainEventBus,
|
|
33
37
|
TaskCreatedEvent,
|
|
@@ -43,6 +47,10 @@ import {
|
|
|
43
47
|
IterationCompletedEvent,
|
|
44
48
|
TeamCreatedEvent,
|
|
45
49
|
ContainerCreatedEvent,
|
|
50
|
+
DeliverableCreatedEvent,
|
|
51
|
+
DeliverableUpdatedEvent,
|
|
52
|
+
DeliverableStatusChangedEvent,
|
|
53
|
+
DeliverableDeletedEvent,
|
|
46
54
|
TaskDependencyAddedEvent,
|
|
47
55
|
TimeLoggedEvent,
|
|
48
56
|
CommentAddedEvent,
|
|
@@ -62,6 +70,7 @@ export class CriticalPathEngine {
|
|
|
62
70
|
public readonly fileStorage?: FileStorageAdapter;
|
|
63
71
|
public readonly plugins: PluginRegistry;
|
|
64
72
|
public readonly events: DomainEventBus;
|
|
73
|
+
public readonly ready: Promise<void> = Promise.resolve();
|
|
65
74
|
|
|
66
75
|
constructor(config: CriticalPathConfig = {}) {
|
|
67
76
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
@@ -79,7 +88,7 @@ export class CriticalPathEngine {
|
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
if (config.initialData) {
|
|
82
|
-
this.seedInitialData(config.initialData);
|
|
91
|
+
this.ready = this.seedInitialData(config.initialData);
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -104,6 +113,11 @@ export class CriticalPathEngine {
|
|
|
104
113
|
await this.store.createContainer(c);
|
|
105
114
|
}
|
|
106
115
|
}
|
|
116
|
+
if (data.deliverables) {
|
|
117
|
+
for (const d of data.deliverables) {
|
|
118
|
+
await this.store.createDeliverable(d);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
107
121
|
if (data.iterations) {
|
|
108
122
|
for (const it of data.iterations) {
|
|
109
123
|
await this.store.createIteration(it);
|
|
@@ -331,6 +345,7 @@ export class CriticalPathEngine {
|
|
|
331
345
|
iterationId: processedInput.iterationId ?? taskInput.iterationId,
|
|
332
346
|
teamId: processedInput.teamId ?? taskInput.teamId,
|
|
333
347
|
containerId: processedInput.containerId ?? taskInput.containerId,
|
|
348
|
+
deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
|
|
334
349
|
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
335
350
|
actualStartDate,
|
|
336
351
|
actualEndDate,
|
|
@@ -624,7 +639,8 @@ export class CriticalPathEngine {
|
|
|
624
639
|
}
|
|
625
640
|
|
|
626
641
|
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
627
|
-
const
|
|
642
|
+
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
643
|
+
const created = await this.store.addComment({ ...comment, mentions });
|
|
628
644
|
const now = new Date().toISOString();
|
|
629
645
|
|
|
630
646
|
const event: CommentAddedEvent = {
|
|
@@ -645,7 +661,10 @@ export class CriticalPathEngine {
|
|
|
645
661
|
const existing = await this.store.getComment(id);
|
|
646
662
|
if (!existing) return null;
|
|
647
663
|
|
|
648
|
-
const
|
|
664
|
+
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
665
|
+
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
666
|
+
|
|
667
|
+
const updated = await this.store.updateComment(id, toUpdate);
|
|
649
668
|
if (!updated) return null;
|
|
650
669
|
|
|
651
670
|
const now = new Date().toISOString();
|
|
@@ -932,6 +951,178 @@ export class CriticalPathEngine {
|
|
|
932
951
|
return this.store.deleteContainer(id);
|
|
933
952
|
}
|
|
934
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
|
+
|
|
935
1126
|
// --- Iterations ---
|
|
936
1127
|
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
937
1128
|
return this.store.getIterations(projectId);
|
package/src/index.test.ts
CHANGED
|
@@ -310,5 +310,35 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
310
310
|
expect(afterRemoval?.reactions?.[0].emoji).toBe('🚀');
|
|
311
311
|
expect(receivedEvents).toContain('comment.reaction.removed');
|
|
312
312
|
});
|
|
313
|
+
|
|
314
|
+
it('supports mentions in comments with auto-extraction and explicit override', async () => {
|
|
315
|
+
const engine = new CriticalPathEngine();
|
|
316
|
+
const proj = await engine.createProject({ key: 'MNT', name: 'Mentions Project' });
|
|
317
|
+
const task = await engine.createTask({ projectId: proj.id, title: 'Mentions Task', status: 'todo' });
|
|
318
|
+
|
|
319
|
+
// Auto-extracted mentions
|
|
320
|
+
const comment1 = await engine.addComment({
|
|
321
|
+
taskId: task.id,
|
|
322
|
+
authorId: 'user_1',
|
|
323
|
+
content: 'Hey @planner and @user_2, please check with @"Supervisor Agent"!'
|
|
324
|
+
});
|
|
325
|
+
expect(comment1.mentions).toEqual(['planner', 'user_2', 'Supervisor Agent']);
|
|
326
|
+
|
|
327
|
+
// Explicit mentions passed
|
|
328
|
+
const comment2 = await engine.addComment({
|
|
329
|
+
taskId: task.id,
|
|
330
|
+
authorId: 'user_1',
|
|
331
|
+
content: 'Custom mention assignment',
|
|
332
|
+
mentions: ['custom_agent_id']
|
|
333
|
+
});
|
|
334
|
+
expect(comment2.mentions).toEqual(['custom_agent_id']);
|
|
335
|
+
|
|
336
|
+
// Update comment content updates mentions
|
|
337
|
+
const updated = await engine.updateComment(comment1.id, {
|
|
338
|
+
content: 'Now calling only @coordinator'
|
|
339
|
+
});
|
|
340
|
+
expect(updated?.mentions).toEqual(['coordinator']);
|
|
341
|
+
});
|
|
313
342
|
});
|
|
314
343
|
|
|
344
|
+
|
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;
|