@critical-path/core 0.35.0 → 0.36.1
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/README.md +11 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +25 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +5 -1
- package/CHANGELOG.md +0 -584
- package/dist/deliverables.test.d.ts +0 -2
- package/dist/deliverables.test.d.ts.map +0 -1
- package/dist/deliverables.test.js +0 -232
- package/dist/deliverables.test.js.map +0 -1
- package/dist/domain/calendar.test.d.ts +0 -2
- package/dist/domain/calendar.test.d.ts.map +0 -1
- package/dist/domain/calendar.test.js +0 -181
- package/dist/domain/calendar.test.js.map +0 -1
- package/dist/domain/cpm.test.d.ts +0 -2
- package/dist/domain/cpm.test.d.ts.map +0 -1
- package/dist/domain/cpm.test.js +0 -166
- package/dist/domain/cpm.test.js.map +0 -1
- package/dist/domain/ddd.test.d.ts +0 -2
- package/dist/domain/ddd.test.d.ts.map +0 -1
- package/dist/domain/ddd.test.js +0 -349
- package/dist/domain/ddd.test.js.map +0 -1
- package/dist/domain/ladder.test.d.ts +0 -2
- package/dist/domain/ladder.test.d.ts.map +0 -1
- package/dist/domain/ladder.test.js +0 -213
- package/dist/domain/ladder.test.js.map +0 -1
- package/dist/domain/metrics.test.d.ts +0 -2
- package/dist/domain/metrics.test.d.ts.map +0 -1
- package/dist/domain/metrics.test.js +0 -252
- package/dist/domain/metrics.test.js.map +0 -1
- package/dist/domain/task-duration.test.d.ts +0 -2
- package/dist/domain/task-duration.test.d.ts.map +0 -1
- package/dist/domain/task-duration.test.js +0 -307
- package/dist/domain/task-duration.test.js.map +0 -1
- package/dist/domain/workload.test.d.ts +0 -2
- package/dist/domain/workload.test.d.ts.map +0 -1
- package/dist/domain/workload.test.js +0 -261
- package/dist/domain/workload.test.js.map +0 -1
- package/dist/engine/actor.test.d.ts +0 -2
- package/dist/engine/actor.test.d.ts.map +0 -1
- package/dist/engine/actor.test.js +0 -54
- package/dist/engine/actor.test.js.map +0 -1
- package/dist/engine/authorization.test.d.ts +0 -2
- package/dist/engine/authorization.test.d.ts.map +0 -1
- package/dist/engine/authorization.test.js +0 -154
- package/dist/engine/authorization.test.js.map +0 -1
- package/dist/engine/cascade.test.d.ts +0 -2
- package/dist/engine/cascade.test.d.ts.map +0 -1
- package/dist/engine/cascade.test.js +0 -99
- package/dist/engine/cascade.test.js.map +0 -1
- package/dist/engine/config.test.d.ts +0 -2
- package/dist/engine/config.test.d.ts.map +0 -1
- package/dist/engine/config.test.js +0 -46
- package/dist/engine/config.test.js.map +0 -1
- package/dist/engine/entity-events.test.d.ts +0 -2
- package/dist/engine/entity-events.test.d.ts.map +0 -1
- package/dist/engine/entity-events.test.js +0 -61
- package/dist/engine/entity-events.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -429
- package/dist/index.test.js.map +0 -1
- package/dist/plugins/plugins.test.d.ts +0 -2
- package/dist/plugins/plugins.test.d.ts.map +0 -1
- package/dist/plugins/plugins.test.js +0 -137
- package/dist/plugins/plugins.test.js.map +0 -1
- package/dist/schemas/schemas.test.d.ts +0 -2
- package/dist/schemas/schemas.test.d.ts.map +0 -1
- package/dist/schemas/schemas.test.js +0 -58
- package/dist/schemas/schemas.test.js.map +0 -1
- package/dist/status.test.d.ts +0 -2
- package/dist/status.test.d.ts.map +0 -1
- package/dist/status.test.js +0 -295
- package/dist/status.test.js.map +0 -1
- package/dist/storage/storage.test.d.ts +0 -2
- package/dist/storage/storage.test.d.ts.map +0 -1
- package/dist/storage/storage.test.js +0 -236
- package/dist/storage/storage.test.js.map +0 -1
- package/dist/store/conformance.test.d.ts +0 -2
- package/dist/store/conformance.test.d.ts.map +0 -1
- package/dist/store/conformance.test.js +0 -15
- package/dist/store/conformance.test.js.map +0 -1
- package/dist/store/firebase.test.d.ts +0 -2
- package/dist/store/firebase.test.d.ts.map +0 -1
- package/dist/store/firebase.test.js +0 -103
- package/dist/store/firebase.test.js.map +0 -1
- package/dist/store/query.test.d.ts +0 -2
- package/dist/store/query.test.d.ts.map +0 -1
- package/dist/store/query.test.js +0 -89
- package/dist/store/query.test.js.map +0 -1
- package/dist/store/sqlite.test.d.ts +0 -2
- package/dist/store/sqlite.test.d.ts.map +0 -1
- package/dist/store/sqlite.test.js +0 -177
- package/dist/store/sqlite.test.js.map +0 -1
- package/dist/task-state.test.d.ts +0 -2
- package/dist/task-state.test.d.ts.map +0 -1
- package/dist/task-state.test.js +0 -423
- package/dist/task-state.test.js.map +0 -1
- package/dist/use-case-validation.test.d.ts +0 -2
- package/dist/use-case-validation.test.d.ts.map +0 -1
- package/dist/use-case-validation.test.js +0 -281
- package/dist/use-case-validation.test.js.map +0 -1
- package/dist/utils/fractional-index.test.d.ts +0 -2
- package/dist/utils/fractional-index.test.d.ts.map +0 -1
- package/dist/utils/fractional-index.test.js +0 -182
- package/dist/utils/fractional-index.test.js.map +0 -1
- package/dist/utils/key.test.d.ts +0 -2
- package/dist/utils/key.test.d.ts.map +0 -1
- package/dist/utils/key.test.js +0 -65
- package/dist/utils/key.test.js.map +0 -1
- package/dist/utils/mentions.test.d.ts +0 -2
- package/dist/utils/mentions.test.d.ts.map +0 -1
- package/dist/utils/mentions.test.js +0 -114
- package/dist/utils/mentions.test.js.map +0 -1
- package/dist/webhooks/webhooks.test.d.ts +0 -2
- package/dist/webhooks/webhooks.test.d.ts.map +0 -1
- package/dist/webhooks/webhooks.test.js +0 -154
- package/dist/webhooks/webhooks.test.js.map +0 -1
- package/src/deliverables.test.ts +0 -284
- package/src/domain/authorization.ts +0 -82
- package/src/domain/calendar.test.ts +0 -216
- package/src/domain/calendar.ts +0 -502
- package/src/domain/cpm.test.ts +0 -189
- package/src/domain/cpm.ts +0 -258
- package/src/domain/custom-fields.ts +0 -172
- package/src/domain/ddd.test.ts +0 -442
- package/src/domain/entities.ts +0 -798
- package/src/domain/errors.ts +0 -38
- package/src/domain/events.ts +0 -369
- package/src/domain/graph.ts +0 -70
- package/src/domain/index.ts +0 -12
- package/src/domain/ladder.test.ts +0 -251
- package/src/domain/ladder.ts +0 -474
- package/src/domain/metrics.test.ts +0 -288
- package/src/domain/metrics.ts +0 -497
- package/src/domain/task-duration.test.ts +0 -354
- package/src/domain/workload.test.ts +0 -311
- package/src/domain/workload.ts +0 -457
- package/src/engine/actor.test.ts +0 -63
- package/src/engine/authorization.test.ts +0 -199
- package/src/engine/cascade.test.ts +0 -121
- package/src/engine/config.test.ts +0 -51
- package/src/engine/entity-events.test.ts +0 -67
- package/src/engine/index.ts +0 -2472
- package/src/index.test.ts +0 -514
- package/src/index.ts +0 -9
- package/src/plugins/index.ts +0 -103
- package/src/plugins/plugins.test.ts +0 -161
- package/src/schemas/index.ts +0 -402
- package/src/schemas/schemas.test.ts +0 -116
- package/src/status.test.ts +0 -354
- package/src/storage/file-storage.ts +0 -200
- package/src/storage/firebase-storage.ts +0 -209
- package/src/storage/index.ts +0 -3
- package/src/storage/s3.ts +0 -151
- package/src/storage/storage.test.ts +0 -274
- package/src/store/conformance.test.ts +0 -15
- package/src/store/firebase.test.ts +0 -131
- package/src/store/firebase.ts +0 -658
- package/src/store/index.ts +0 -623
- package/src/store/query.test.ts +0 -110
- package/src/store/query.ts +0 -113
- package/src/store/sqlite.test.ts +0 -218
- package/src/store/sqlite.ts +0 -1480
- package/src/task-state.test.ts +0 -506
- package/src/testing/conformance.ts +0 -319
- package/src/testing/index.ts +0 -1
- package/src/types/index.ts +0 -859
- package/src/use-case-validation.test.ts +0 -324
- package/src/utils/fractional-index.test.ts +0 -228
- package/src/utils/fractional-index.ts +0 -254
- package/src/utils/index.ts +0 -7
- package/src/utils/key.test.ts +0 -76
- package/src/utils/key.ts +0 -62
- package/src/utils/mentions.test.ts +0 -132
- package/src/utils/mentions.ts +0 -131
- package/src/utils/status.ts +0 -419
- package/src/utils/workflow.ts +0 -306
- package/src/webhooks/dispatcher.ts +0 -190
- package/src/webhooks/index.ts +0 -2
- package/src/webhooks/signature.ts +0 -57
- package/src/webhooks/webhooks.test.ts +0 -201
- package/tsconfig.json +0 -8
- package/tsconfig.tsbuildinfo +0 -1
package/src/engine/index.ts
DELETED
|
@@ -1,2472 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Activity,
|
|
3
|
-
User,
|
|
4
|
-
Actor,
|
|
5
|
-
AuthorizationAction,
|
|
6
|
-
CriticalPathConfig,
|
|
7
|
-
Project,
|
|
8
|
-
Task,
|
|
9
|
-
TaskDependency,
|
|
10
|
-
TaskDependencyGraph,
|
|
11
|
-
Team,
|
|
12
|
-
TaskContainer,
|
|
13
|
-
Iteration,
|
|
14
|
-
Comment,
|
|
15
|
-
Attachment,
|
|
16
|
-
FileStorageAdapter,
|
|
17
|
-
UploadFileInput,
|
|
18
|
-
PresignedUrlOptions,
|
|
19
|
-
PresignedUploadResult,
|
|
20
|
-
TimeEntry,
|
|
21
|
-
Webhook,
|
|
22
|
-
PublicWebhook,
|
|
23
|
-
Workflow,
|
|
24
|
-
CreateTaskInput,
|
|
25
|
-
CreateProjectInput,
|
|
26
|
-
Deliverable,
|
|
27
|
-
DeliverableSummary,
|
|
28
|
-
CreateDeliverableInput,
|
|
29
|
-
TaskLifecycleState,
|
|
30
|
-
CriticalPathAnalysis,
|
|
31
|
-
TimelineLadder,
|
|
32
|
-
TimelineLadderOptions,
|
|
33
|
-
TaskLadderView,
|
|
34
|
-
MacroPhaseRollup,
|
|
35
|
-
StandardTaskTimelineItem,
|
|
36
|
-
ConcreteTaskEvidence,
|
|
37
|
-
TaskMetrics,
|
|
38
|
-
TaskProgressHistory,
|
|
39
|
-
WorkloadDistribution,
|
|
40
|
-
WorkloadDistributionOptions
|
|
41
|
-
} from '../types/index.js';
|
|
42
|
-
import { StorageAdapter, InMemoryStore, type ActivityQuery, type Page, type TaskQuery } from '../store/index.js';
|
|
43
|
-
import { PluginRegistry } from '../plugins/index.js';
|
|
44
|
-
import { deriveTaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
|
|
45
|
-
import {
|
|
46
|
-
validateTransition,
|
|
47
|
-
getAllowedNextStatuses,
|
|
48
|
-
getAllowedPreviousStatuses,
|
|
49
|
-
getAllowedTransitions,
|
|
50
|
-
WorkflowValidationError,
|
|
51
|
-
DEFAULT_SOFTWARE_WORKFLOW
|
|
52
|
-
} from '../utils/workflow.js';
|
|
53
|
-
import { extractMentions } from '../utils/mentions.js';
|
|
54
|
-
import {
|
|
55
|
-
DomainEventBus,
|
|
56
|
-
TaskCreatedEvent,
|
|
57
|
-
TaskUpdatedEvent,
|
|
58
|
-
TaskStatusChangedEvent,
|
|
59
|
-
TaskBlockedEvent,
|
|
60
|
-
TaskUnblockedEvent,
|
|
61
|
-
TaskDeletedEvent,
|
|
62
|
-
ProjectCreatedEvent,
|
|
63
|
-
ProjectUpdatedEvent,
|
|
64
|
-
ProjectDeletedEvent,
|
|
65
|
-
TaskDependencyRemovedEvent,
|
|
66
|
-
TeamUpdatedEvent,
|
|
67
|
-
TeamDeletedEvent,
|
|
68
|
-
ContainerUpdatedEvent,
|
|
69
|
-
ContainerDeletedEvent,
|
|
70
|
-
IterationCreatedEvent,
|
|
71
|
-
IterationUpdatedEvent,
|
|
72
|
-
IterationDeletedEvent,
|
|
73
|
-
CriticalPathDomainEvent,
|
|
74
|
-
WorkflowCreatedEvent,
|
|
75
|
-
WorkflowUpdatedEvent,
|
|
76
|
-
WorkflowDeletedEvent,
|
|
77
|
-
IterationStartedEvent,
|
|
78
|
-
IterationCompletedEvent,
|
|
79
|
-
TeamCreatedEvent,
|
|
80
|
-
ContainerCreatedEvent,
|
|
81
|
-
DeliverableCreatedEvent,
|
|
82
|
-
DeliverableUpdatedEvent,
|
|
83
|
-
DeliverableStatusChangedEvent,
|
|
84
|
-
DeliverableDeletedEvent,
|
|
85
|
-
TaskDependencyAddedEvent,
|
|
86
|
-
TimeLoggedEvent,
|
|
87
|
-
CommentAddedEvent,
|
|
88
|
-
CommentUpdatedEvent,
|
|
89
|
-
CommentDeletedEvent,
|
|
90
|
-
CommentReactionAddedEvent,
|
|
91
|
-
CommentReactionRemovedEvent,
|
|
92
|
-
AttachmentCreatedEvent,
|
|
93
|
-
AttachmentDeletedEvent
|
|
94
|
-
} from '../domain/events.js';
|
|
95
|
-
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
96
|
-
import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
|
|
97
|
-
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
98
|
-
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
99
|
-
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
100
|
-
import { buildStorageKey } from '../storage/file-storage.js';
|
|
101
|
-
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
102
|
-
import type { DomainEvent } from '../domain/events.js';
|
|
103
|
-
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
104
|
-
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
105
|
-
import {
|
|
106
|
-
calculateTaskMetrics,
|
|
107
|
-
reconstructTaskProgressHistory,
|
|
108
|
-
type MetricOptions
|
|
109
|
-
} from '../domain/metrics.js';
|
|
110
|
-
import { calculateWorkloadDistribution } from '../domain/workload.js';
|
|
111
|
-
|
|
112
|
-
export class CriticalPathEngine {
|
|
113
|
-
public readonly config: CriticalPathConfig;
|
|
114
|
-
public readonly store: StorageAdapter;
|
|
115
|
-
public readonly fileStorage?: FileStorageAdapter;
|
|
116
|
-
public readonly plugins: PluginRegistry;
|
|
117
|
-
public readonly events: DomainEventBus;
|
|
118
|
-
/** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
|
|
119
|
-
public readonly webhooks: WebhookDispatcher;
|
|
120
|
-
public readonly ready: Promise<void> = Promise.resolve();
|
|
121
|
-
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
122
|
-
public readonly actor?: Actor;
|
|
123
|
-
/** Set on internal views that keep the actor for attribution but skip authorization. */
|
|
124
|
-
private readonly authorizationBypassed?: boolean;
|
|
125
|
-
|
|
126
|
-
constructor(config: CriticalPathConfig = {}) {
|
|
127
|
-
this.config = config;
|
|
128
|
-
if (config.store !== undefined && (typeof config.store !== 'object' || config.store === null)) {
|
|
129
|
-
// Strings like 'sqlite' used to fall back to an in-memory store silently.
|
|
130
|
-
throw new Error(
|
|
131
|
-
`CriticalPathEngine "store" must be a storage adapter instance, e.g. new SQLiteStore({ filename: 'app.db' }). Received ${JSON.stringify(config.store)}.`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
this.store = config.store ?? new InMemoryStore();
|
|
135
|
-
|
|
136
|
-
this.fileStorage = config.fileStorage;
|
|
137
|
-
this.plugins = new PluginRegistry();
|
|
138
|
-
this.events = new DomainEventBus();
|
|
139
|
-
this.webhooks = new WebhookDispatcher(
|
|
140
|
-
{
|
|
141
|
-
listWebhooks: () => this.listAllWebhooks(),
|
|
142
|
-
resolveTenant: (event) => this.resolveEventTenant(event)
|
|
143
|
-
},
|
|
144
|
-
config.webhookDelivery
|
|
145
|
-
);
|
|
146
|
-
this.events.subscribe('*', (event) => this.webhooks.handle(event));
|
|
147
|
-
|
|
148
|
-
if (config.plugins) {
|
|
149
|
-
for (const plugin of config.plugins) {
|
|
150
|
-
this.plugins.register(plugin);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
this.ready = this.initialize(config);
|
|
155
|
-
// Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
|
|
156
|
-
this.ready.catch(() => {});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Returns a view of this engine that attributes every mutation to `actor`: activity log
|
|
161
|
-
* entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
|
|
162
|
-
* Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
|
|
163
|
-
* someone else. The view shares the store, plugins and event bus, and does not modify this engine,
|
|
164
|
-
* so it is safe to create one per request.
|
|
165
|
-
*/
|
|
166
|
-
withActor(actor: Actor): CriticalPathEngine {
|
|
167
|
-
const scoped = Object.create(this) as CriticalPathEngine;
|
|
168
|
-
Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
|
|
169
|
-
return scoped;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Publishes a domain event, filling in its id and timestamp. */
|
|
173
|
-
private async publishEvent<E extends CriticalPathDomainEvent>(event: Omit<E, 'id' | 'occurredAt'>): Promise<void> {
|
|
174
|
-
await this.events.publish({
|
|
175
|
-
...event,
|
|
176
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
177
|
-
occurredAt: new Date().toISOString()
|
|
178
|
-
} as E);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private actorIdOr(fallback: string): string {
|
|
182
|
-
return this.actor?.userId ?? fallback;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// --- Authorization (applies only to withActor views; the base engine is trusted) ---
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* True when calls must be checked: on a withActor view (not an internal elevated view) with a
|
|
189
|
-
* policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
|
|
190
|
-
*/
|
|
191
|
-
private get enforcing(): boolean {
|
|
192
|
-
return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
|
|
196
|
-
private elevated(): CriticalPathEngine {
|
|
197
|
-
if (!this.enforcing) return this;
|
|
198
|
-
const view = Object.create(this) as CriticalPathEngine;
|
|
199
|
-
Object.defineProperty(view, 'authorizationBypassed', { value: true });
|
|
200
|
-
return view;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
|
|
204
|
-
const tenant = this.actor?.tenantId;
|
|
205
|
-
return !tenant || entity?.tenantId === tenant;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private async isAllowed(
|
|
209
|
-
action: AuthorizationAction,
|
|
210
|
-
project?: Project,
|
|
211
|
-
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
212
|
-
): Promise<boolean> {
|
|
213
|
-
if (!this.enforcing) return true;
|
|
214
|
-
if (project && !this.inActorTenant(project)) return false;
|
|
215
|
-
const policy = this.config.authorize;
|
|
216
|
-
return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** The project if the actor may read it, otherwise null (callers report "not found"). */
|
|
220
|
-
private async readableProject(projectId: string | undefined): Promise<Project | null> {
|
|
221
|
-
if (!projectId) return null;
|
|
222
|
-
const project = await this.store.getProject(projectId);
|
|
223
|
-
if (!project) return null;
|
|
224
|
-
return (await this.isAllowed('project.read', project)) ? project : null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private async canReadProject(projectId: string | undefined): Promise<boolean> {
|
|
228
|
-
if (!this.enforcing) return true;
|
|
229
|
-
return (await this.readableProject(projectId)) !== null;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
|
|
234
|
-
* that belong to another tenant) are reported as not found so their existence is not revealed.
|
|
235
|
-
*/
|
|
236
|
-
private async requireProjectAccess(
|
|
237
|
-
action: AuthorizationAction,
|
|
238
|
-
projectId: string | undefined,
|
|
239
|
-
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
240
|
-
): Promise<void> {
|
|
241
|
-
if (!this.enforcing) return;
|
|
242
|
-
const project = await this.readableProject(projectId);
|
|
243
|
-
if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
|
|
244
|
-
if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
|
|
245
|
-
throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
|
|
250
|
-
if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
|
|
254
|
-
private async filterReadable<T>(
|
|
255
|
-
items: T[],
|
|
256
|
-
projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
|
|
257
|
-
): Promise<T[]> {
|
|
258
|
-
if (!this.enforcing) return items;
|
|
259
|
-
const readable = new Map<string, boolean>();
|
|
260
|
-
const result: T[] = [];
|
|
261
|
-
for (const item of items) {
|
|
262
|
-
const projectId = await projectIdOf(item);
|
|
263
|
-
if (!projectId) continue;
|
|
264
|
-
if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
|
|
265
|
-
if (readable.get(projectId)) result.push(item);
|
|
266
|
-
}
|
|
267
|
-
return result;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
|
|
271
|
-
return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
|
|
275
|
-
if (!commentId) return undefined;
|
|
276
|
-
const comment = await this.store.getComment(commentId);
|
|
277
|
-
return this.projectIdOfTask(comment?.taskId);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
|
|
281
|
-
return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Strips fields only the engine may set (tenancy) from updates made through a view. */
|
|
285
|
-
private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
|
|
286
|
-
if (!this.actor || !('tenantId' in updates)) return updates;
|
|
287
|
-
const { tenantId: _ignored, ...rest } = updates;
|
|
288
|
-
return rest as T;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private async initialize(config: CriticalPathConfig): Promise<void> {
|
|
292
|
-
if (config.initialData) {
|
|
293
|
-
await this.seedInitialData(config.initialData);
|
|
294
|
-
}
|
|
295
|
-
for (const plugin of this.plugins.getPlugins()) {
|
|
296
|
-
await plugin.init?.(this);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
301
|
-
if (data.workflows) {
|
|
302
|
-
for (const wf of data.workflows) {
|
|
303
|
-
await this.store.createWorkflow(wf);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
if (data.projects) {
|
|
307
|
-
for (const p of data.projects) {
|
|
308
|
-
await this.store.createProject(p);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
if (data.teams) {
|
|
312
|
-
for (const tm of data.teams) {
|
|
313
|
-
await this.store.createTeam(tm);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (data.containers) {
|
|
317
|
-
for (const c of data.containers) {
|
|
318
|
-
await this.store.createContainer(c);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (data.deliverables) {
|
|
322
|
-
for (const d of data.deliverables) {
|
|
323
|
-
await this.store.createDeliverable(d);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
if (data.iterations) {
|
|
327
|
-
for (const it of data.iterations) {
|
|
328
|
-
await this.store.createIteration(it);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (data.tasks) {
|
|
332
|
-
for (const t of data.tasks) {
|
|
333
|
-
await this.store.createTask(t);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// --- Workflows ---
|
|
339
|
-
async getWorkflows(): Promise<Workflow[]> {
|
|
340
|
-
const workflows = await this.store.getWorkflows();
|
|
341
|
-
return workflows.filter((w) => this.inActorTenant(w));
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async getWorkflow(id: string): Promise<Workflow | null> {
|
|
345
|
-
const workflow = await this.store.getWorkflow(id);
|
|
346
|
-
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
350
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
351
|
-
const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
|
|
352
|
-
const now = new Date().toISOString();
|
|
353
|
-
|
|
354
|
-
const event: WorkflowCreatedEvent = {
|
|
355
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
356
|
-
name: 'workflow.created',
|
|
357
|
-
aggregateId: created.id,
|
|
358
|
-
aggregateType: 'Workflow',
|
|
359
|
-
occurredAt: now,
|
|
360
|
-
payload: { workflow: created }
|
|
361
|
-
};
|
|
362
|
-
await this.events.publish(event);
|
|
363
|
-
|
|
364
|
-
await this.store.logActivity({
|
|
365
|
-
actorId: this.actorIdOr('system'),
|
|
366
|
-
action: 'workflow.created',
|
|
367
|
-
details: { name: created.name }
|
|
368
|
-
});
|
|
369
|
-
return created;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
|
|
373
|
-
const existing = await this.getWorkflow(id);
|
|
374
|
-
if (!existing) return null;
|
|
375
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
376
|
-
updates = this.withoutTenant(updates);
|
|
377
|
-
|
|
378
|
-
const updated = await this.store.updateWorkflow(id, updates);
|
|
379
|
-
if (updated) {
|
|
380
|
-
const now = new Date().toISOString();
|
|
381
|
-
const event: WorkflowUpdatedEvent = {
|
|
382
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
383
|
-
name: 'workflow.updated',
|
|
384
|
-
aggregateId: updated.id,
|
|
385
|
-
aggregateType: 'Workflow',
|
|
386
|
-
occurredAt: now,
|
|
387
|
-
payload: { workflow: updated, previous: existing }
|
|
388
|
-
};
|
|
389
|
-
await this.events.publish(event);
|
|
390
|
-
|
|
391
|
-
await this.store.logActivity({
|
|
392
|
-
actorId: this.actorIdOr('system'),
|
|
393
|
-
action: 'workflow.updated',
|
|
394
|
-
details: { name: updated.name }
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
return updated;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async deleteWorkflow(id: string): Promise<boolean> {
|
|
401
|
-
const existing = await this.getWorkflow(id);
|
|
402
|
-
if (!existing) return false;
|
|
403
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
404
|
-
|
|
405
|
-
const deleted = await this.store.deleteWorkflow(id);
|
|
406
|
-
if (deleted) {
|
|
407
|
-
const now = new Date().toISOString();
|
|
408
|
-
const event: WorkflowDeletedEvent = {
|
|
409
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
410
|
-
name: 'workflow.deleted',
|
|
411
|
-
aggregateId: id,
|
|
412
|
-
aggregateType: 'Workflow',
|
|
413
|
-
occurredAt: now,
|
|
414
|
-
payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
|
|
415
|
-
};
|
|
416
|
-
await this.events.publish(event);
|
|
417
|
-
|
|
418
|
-
await this.store.logActivity({
|
|
419
|
-
actorId: this.actorIdOr('system'),
|
|
420
|
-
action: 'workflow.deleted',
|
|
421
|
-
details: { name: existing.name }
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
return deleted;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async resolveProjectWorkflow(projectId: string): Promise<Workflow | null> {
|
|
428
|
-
const project = await this.store.getProject(projectId);
|
|
429
|
-
if (project?.workflow) return project.workflow;
|
|
430
|
-
if (project?.workflowId) {
|
|
431
|
-
const wf = await this.store.getWorkflow(project.workflowId);
|
|
432
|
-
if (wf) return wf;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Only fall back to workflows from the project's own tenant.
|
|
436
|
-
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
|
|
437
|
-
const defaultWf = workflows.find((w) => w.isDefault);
|
|
438
|
-
if (defaultWf) return defaultWf;
|
|
439
|
-
if (workflows.length > 0) return workflows[0];
|
|
440
|
-
|
|
441
|
-
return DEFAULT_SOFTWARE_WORKFLOW;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
|
|
445
|
-
const task = await this.getTask(taskId);
|
|
446
|
-
if (!task) return [];
|
|
447
|
-
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
448
|
-
return getAllowedTransitions(workflow || undefined, task.status);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
|
|
452
|
-
const task = await this.getTask(taskId);
|
|
453
|
-
if (!task) return [];
|
|
454
|
-
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
455
|
-
return getAllowedNextStatuses(workflow || undefined, task.status);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
|
|
459
|
-
const task = await this.getTask(taskId);
|
|
460
|
-
if (!task) return [];
|
|
461
|
-
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
462
|
-
return getAllowedPreviousStatuses(workflow || undefined, task.status);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// --- Projects ---
|
|
466
|
-
async getProjects(): Promise<Project[]> {
|
|
467
|
-
const projects = await this.store.getProjects();
|
|
468
|
-
return this.filterReadable(projects, (p) => p.id);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
async getProject(id: string): Promise<Project | null> {
|
|
472
|
-
if (!this.enforcing) return this.store.getProject(id);
|
|
473
|
-
return this.readableProject(id);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
477
|
-
await this.requireWorkspaceAccess('project.create');
|
|
478
|
-
validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
|
|
479
|
-
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
480
|
-
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
481
|
-
// The creator administers the project they create.
|
|
482
|
-
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
|
|
483
|
-
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
484
|
-
}
|
|
485
|
-
const created = await this.store.createProject(input);
|
|
486
|
-
const now = new Date().toISOString();
|
|
487
|
-
|
|
488
|
-
const event: ProjectCreatedEvent = {
|
|
489
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
490
|
-
name: 'project.created',
|
|
491
|
-
aggregateId: created.id,
|
|
492
|
-
aggregateType: 'Project',
|
|
493
|
-
occurredAt: now,
|
|
494
|
-
payload: { project: created }
|
|
495
|
-
};
|
|
496
|
-
await this.events.publish(event);
|
|
497
|
-
|
|
498
|
-
await this.store.logActivity({
|
|
499
|
-
projectId: created.id,
|
|
500
|
-
actorId: this.actorIdOr(project.ownerId || 'system'),
|
|
501
|
-
action: 'project.created',
|
|
502
|
-
details: { name: created.name, key: created.key }
|
|
503
|
-
});
|
|
504
|
-
return created;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
|
|
508
|
-
const existing = await this.getProject(id);
|
|
509
|
-
if (!existing) return null;
|
|
510
|
-
await this.requireProjectAccess('project.update', id);
|
|
511
|
-
if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
|
|
512
|
-
validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
|
|
513
|
-
updates = this.withoutTenant(updates);
|
|
514
|
-
|
|
515
|
-
const updated = await this.store.updateProject(id, updates);
|
|
516
|
-
if (updated) {
|
|
517
|
-
const now = new Date().toISOString();
|
|
518
|
-
const event: ProjectUpdatedEvent = {
|
|
519
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
520
|
-
name: 'project.updated',
|
|
521
|
-
aggregateId: updated.id,
|
|
522
|
-
aggregateType: 'Project',
|
|
523
|
-
occurredAt: now,
|
|
524
|
-
payload: { project: updated, previous: existing }
|
|
525
|
-
};
|
|
526
|
-
await this.events.publish(event);
|
|
527
|
-
|
|
528
|
-
await this.store.logActivity({
|
|
529
|
-
projectId: updated.id,
|
|
530
|
-
actorId: this.actorIdOr('system'),
|
|
531
|
-
action: 'project.updated',
|
|
532
|
-
details: { name: updated.name }
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
return updated;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Deletes a project and its tasks. Each task goes through `deleteTask`, so plugin
|
|
540
|
-
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
541
|
-
*/
|
|
542
|
-
async deleteProject(id: string): Promise<boolean> {
|
|
543
|
-
const existing = await this.getProject(id);
|
|
544
|
-
if (!existing) return false;
|
|
545
|
-
await this.requireProjectAccess('project.delete', id);
|
|
546
|
-
// Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
|
|
547
|
-
const cascade = this.elevated();
|
|
548
|
-
|
|
549
|
-
const tasks = await this.store.getTasks(id);
|
|
550
|
-
for (const task of tasks) {
|
|
551
|
-
// Subtasks may already be gone with their parent; deleteTask then returns false.
|
|
552
|
-
await cascade.deleteTask(task.id);
|
|
553
|
-
}
|
|
554
|
-
const deletedTaskIds = (
|
|
555
|
-
await Promise.all(tasks.map(async (t) => ((await this.store.getTask(t.id)) ? null : t.id)))
|
|
556
|
-
).filter((taskId): taskId is string => taskId !== null);
|
|
557
|
-
|
|
558
|
-
for (const container of await this.store.getContainers(id)) await this.store.deleteContainer(container.id);
|
|
559
|
-
for (const iteration of await this.store.getIterations(id)) await this.store.deleteIteration(iteration.id);
|
|
560
|
-
for (const deliverable of await this.store.getDeliverables(id)) await cascade.deleteDeliverable(deliverable.id);
|
|
561
|
-
for (const attachment of await this.store.getAttachments({ projectId: id })) await cascade.deleteAttachment(attachment.id);
|
|
562
|
-
|
|
563
|
-
const deleted = await this.store.deleteProject(id);
|
|
564
|
-
if (deleted) {
|
|
565
|
-
const now = new Date().toISOString();
|
|
566
|
-
const event: ProjectDeletedEvent = {
|
|
567
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
568
|
-
name: 'project.deleted',
|
|
569
|
-
aggregateId: id,
|
|
570
|
-
aggregateType: 'Project',
|
|
571
|
-
occurredAt: now,
|
|
572
|
-
payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
|
|
573
|
-
};
|
|
574
|
-
await this.events.publish(event);
|
|
575
|
-
|
|
576
|
-
await this.store.logActivity({
|
|
577
|
-
projectId: id,
|
|
578
|
-
actorId: this.actorIdOr('system'),
|
|
579
|
-
action: 'project.deleted',
|
|
580
|
-
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
return deleted;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// --- Tasks ---
|
|
587
|
-
async getTasks(projectId?: string): Promise<Task[]> {
|
|
588
|
-
if (projectId && !(await this.canReadProject(projectId))) return [];
|
|
589
|
-
const tasks = await this.store.getTasks(projectId);
|
|
590
|
-
return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Filtered, paginated tasks (oldest first). Without `projectId`, results span every project
|
|
595
|
-
* the actor can read.
|
|
596
|
-
*/
|
|
597
|
-
async queryTasks(query: TaskQuery = {}): Promise<Page<Task>> {
|
|
598
|
-
if (query.projectId) {
|
|
599
|
-
return (await this.canReadProject(query.projectId)) ? this.store.queryTasks(query) : { items: [] };
|
|
600
|
-
}
|
|
601
|
-
if (!this.enforcing) return this.store.queryTasks(query);
|
|
602
|
-
return this.store.queryTasks({ ...query, projectIds: await this.readableProjectIds(query.projectIds) });
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/** Paginated activity feed (newest first), limited to projects the actor can read. */
|
|
606
|
-
async queryActivities(query: ActivityQuery = {}): Promise<Page<Activity>> {
|
|
607
|
-
if (query.taskId) {
|
|
608
|
-
return (await this.getTask(query.taskId)) ? this.store.queryActivities(query) : { items: [] };
|
|
609
|
-
}
|
|
610
|
-
if (query.projectId) {
|
|
611
|
-
return (await this.canReadProject(query.projectId)) ? this.store.queryActivities(query) : { items: [] };
|
|
612
|
-
}
|
|
613
|
-
if (!this.enforcing) return this.store.queryActivities(query);
|
|
614
|
-
return this.store.queryActivities({ ...query, projectIds: await this.readableProjectIds(query.projectIds) });
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
private async readableProjectIds(requested?: string[]): Promise<string[]> {
|
|
618
|
-
const readable = (await this.getProjects()).map((p) => p.id);
|
|
619
|
-
return requested ? requested.filter((id) => readable.includes(id)) : readable;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
async getTask(id: string): Promise<Task | null> {
|
|
623
|
-
const task = await this.store.getTask(id);
|
|
624
|
-
if (!task) return null;
|
|
625
|
-
return (await this.canReadProject(task.projectId)) ? task : null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
629
|
-
await this.requireProjectAccess('task.create', taskInput.projectId);
|
|
630
|
-
if (this.actor && !taskInput.reporterId) {
|
|
631
|
-
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
632
|
-
}
|
|
633
|
-
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
634
|
-
if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
|
|
635
|
-
throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
|
|
636
|
-
}
|
|
637
|
-
const projectId = taskInput.projectId;
|
|
638
|
-
const project = await this.store.getProject(projectId);
|
|
639
|
-
const workflow = await this.resolveProjectWorkflow(projectId);
|
|
640
|
-
|
|
641
|
-
// Validate custom fields on the final (post-plugin) input, including required fields when none are given
|
|
642
|
-
validateCustomFieldValues(
|
|
643
|
-
project?.customFieldDefinitions,
|
|
644
|
-
processedInput.customFields ?? taskInput.customFields,
|
|
645
|
-
this.plugins.getCustomFieldTypes()
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
const defaultStatus = workflow?.defaultStatusKey || 'todo';
|
|
649
|
-
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
650
|
-
const now = new Date().toISOString();
|
|
651
|
-
|
|
652
|
-
// Derive initial lifecycle timestamps and semantic status
|
|
653
|
-
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
654
|
-
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
655
|
-
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
656
|
-
const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
657
|
-
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
658
|
-
const completedAt = processedInput.completedAt ?? taskInput.completedAt ?? (statusDef.category === 'completed' ? now : undefined);
|
|
659
|
-
|
|
660
|
-
const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
|
|
661
|
-
const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
|
|
662
|
-
const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
|
|
663
|
-
|
|
664
|
-
const created = await this.store.createTask({
|
|
665
|
-
projectId,
|
|
666
|
-
title: processedInput.title || taskInput.title,
|
|
667
|
-
description: processedInput.description ?? taskInput.description,
|
|
668
|
-
status: initialStatus,
|
|
669
|
-
semanticStatus,
|
|
670
|
-
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
671
|
-
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
672
|
-
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
673
|
-
assignees: processedInput.assignees ?? taskInput.assignees,
|
|
674
|
-
reporterId: processedInput.reporterId ?? taskInput.reporterId,
|
|
675
|
-
reviewerId: processedInput.reviewerId ?? taskInput.reviewerId,
|
|
676
|
-
iterationId: processedInput.iterationId ?? taskInput.iterationId,
|
|
677
|
-
teamId: processedInput.teamId ?? taskInput.teamId,
|
|
678
|
-
containerId: processedInput.containerId ?? taskInput.containerId,
|
|
679
|
-
deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
|
|
680
|
-
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
681
|
-
actualStartDate,
|
|
682
|
-
actualEndDate,
|
|
683
|
-
completedAt,
|
|
684
|
-
dueDate: processedInput.dueDate ?? taskInput.dueDate,
|
|
685
|
-
estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
|
|
686
|
-
loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
|
|
687
|
-
actualHours: processedInput.actualHours ?? taskInput.actualHours,
|
|
688
|
-
billableHours: processedInput.billableHours ?? taskInput.billableHours,
|
|
689
|
-
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
690
|
-
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
691
|
-
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
692
|
-
actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
|
|
693
|
-
inProgressSince,
|
|
694
|
-
blockedDurationSeconds,
|
|
695
|
-
blockedSince,
|
|
696
|
-
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
697
|
-
isBlocked: isInitialBlocked,
|
|
698
|
-
blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
|
|
699
|
-
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
700
|
-
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
701
|
-
parentId: processedInput.parentId ?? taskInput.parentId
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
await this.plugins.runAfterTaskCreate(created);
|
|
705
|
-
|
|
706
|
-
// Publish typed Domain Event
|
|
707
|
-
const event: TaskCreatedEvent = {
|
|
708
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
709
|
-
name: 'task.created',
|
|
710
|
-
aggregateId: created.id,
|
|
711
|
-
aggregateType: 'Task',
|
|
712
|
-
occurredAt: now,
|
|
713
|
-
payload: { task: created }
|
|
714
|
-
};
|
|
715
|
-
await this.events.publish(event);
|
|
716
|
-
|
|
717
|
-
await this.store.logActivity({
|
|
718
|
-
projectId: created.projectId,
|
|
719
|
-
taskId: created.id,
|
|
720
|
-
actorId: this.actorIdOr(created.reporterId || 'system'),
|
|
721
|
-
action: 'task.created',
|
|
722
|
-
details: { title: created.title, status: created.status }
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
return created;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
async updateTask(
|
|
729
|
-
id: string,
|
|
730
|
-
updates: Partial<Task>,
|
|
731
|
-
options?: {
|
|
732
|
-
actorId?: string;
|
|
733
|
-
actorName?: string;
|
|
734
|
-
actorType?: string;
|
|
735
|
-
actor?: { userId: string; username?: string; actorType?: string };
|
|
736
|
-
}
|
|
737
|
-
): Promise<Task | null> {
|
|
738
|
-
const existing = await this.getTask(id);
|
|
739
|
-
if (!existing) return null;
|
|
740
|
-
await this.requireProjectAccess('task.update', existing.projectId);
|
|
741
|
-
|
|
742
|
-
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
743
|
-
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
744
|
-
const actorId = options?.actorId || options?.actor?.userId || 'system';
|
|
745
|
-
const actorName =
|
|
746
|
-
options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
|
|
747
|
-
const actorType =
|
|
748
|
-
options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
|
|
749
|
-
const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
|
|
750
|
-
const taskUpdates = updates;
|
|
751
|
-
|
|
752
|
-
const project = await this.store.getProject(existing.projectId);
|
|
753
|
-
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
754
|
-
|
|
755
|
-
// Plugins run first; their output is then validated like caller input. Identity and
|
|
756
|
-
// ownership fields are never changed by hooks.
|
|
757
|
-
const {
|
|
758
|
-
id: _id,
|
|
759
|
-
projectId: _projectId,
|
|
760
|
-
createdAt: _createdAt,
|
|
761
|
-
...processedUpdates
|
|
762
|
-
} = await this.plugins.runBeforeTaskUpdate(id, taskUpdates) as Partial<Task>;
|
|
763
|
-
|
|
764
|
-
// Validate workflow transition invariant
|
|
765
|
-
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
766
|
-
const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
|
|
767
|
-
if (!isValid) {
|
|
768
|
-
throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Validate custom field invariants
|
|
773
|
-
if (processedUpdates.customFields) {
|
|
774
|
-
validateCustomFieldValues(
|
|
775
|
-
project?.customFieldDefinitions,
|
|
776
|
-
{ ...existing.customFields, ...processedUpdates.customFields },
|
|
777
|
-
this.plugins.getCustomFieldTypes()
|
|
778
|
-
);
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const now = new Date().toISOString();
|
|
782
|
-
const existingStatusDef = resolveStatusDefinition(
|
|
783
|
-
existing.status,
|
|
784
|
-
project?.statusDefinitions || workflow?.statuses
|
|
785
|
-
);
|
|
786
|
-
const newStatusDef = processedUpdates.status
|
|
787
|
-
? resolveStatusDefinition(
|
|
788
|
-
processedUpdates.status,
|
|
789
|
-
project?.statusDefinitions || workflow?.statuses
|
|
790
|
-
)
|
|
791
|
-
: undefined;
|
|
792
|
-
const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
|
|
793
|
-
|
|
794
|
-
// Auto-update execution/completion timestamps if status changes
|
|
795
|
-
if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
|
|
796
|
-
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
|
|
797
|
-
|
|
798
|
-
// 1. Leaving in_progress: finalize active session duration and blocked duration
|
|
799
|
-
if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
|
|
800
|
-
const inProgressSince = existing.inProgressSince;
|
|
801
|
-
if (inProgressSince) {
|
|
802
|
-
const startMs = new Date(inProgressSince).getTime();
|
|
803
|
-
const endMs = new Date(now).getTime();
|
|
804
|
-
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
805
|
-
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
806
|
-
processedUpdates.actualDurationSeconds =
|
|
807
|
-
(processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
|
|
808
|
-
if (processedUpdates.actualHours === undefined) {
|
|
809
|
-
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
810
|
-
}
|
|
811
|
-
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
812
|
-
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
processedUpdates.inProgressSince = null;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
|
|
819
|
-
if (blockedSince) {
|
|
820
|
-
const startMs = new Date(blockedSince).getTime();
|
|
821
|
-
const endMs = new Date(now).getTime();
|
|
822
|
-
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
823
|
-
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
824
|
-
processedUpdates.blockedDurationSeconds =
|
|
825
|
-
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
826
|
-
}
|
|
827
|
-
processedUpdates.blockedSince = null;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
|
|
832
|
-
if (newStatusDef.category === 'in_progress') {
|
|
833
|
-
if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
834
|
-
processedUpdates.actualStartDate = now;
|
|
835
|
-
}
|
|
836
|
-
if (!processedUpdates.inProgressSince) {
|
|
837
|
-
processedUpdates.inProgressSince = now;
|
|
838
|
-
}
|
|
839
|
-
const isTaskBlocked = processedUpdates.isBlocked !== undefined
|
|
840
|
-
? Boolean(processedUpdates.isBlocked)
|
|
841
|
-
: Boolean(existing.isBlocked);
|
|
842
|
-
if (isTaskBlocked && !processedUpdates.blockedSince) {
|
|
843
|
-
processedUpdates.blockedSince = now;
|
|
844
|
-
}
|
|
845
|
-
// Moving from completed/canceled to in_progress resets completion timestamp and progress
|
|
846
|
-
if (
|
|
847
|
-
existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
|
|
848
|
-
) {
|
|
849
|
-
if (processedUpdates.actualEndDate === undefined) {
|
|
850
|
-
processedUpdates.actualEndDate = undefined;
|
|
851
|
-
}
|
|
852
|
-
if (processedUpdates.completedAt === undefined) {
|
|
853
|
-
processedUpdates.completedAt = undefined;
|
|
854
|
-
}
|
|
855
|
-
if (existing.progress === 100 && processedUpdates.progress === undefined) {
|
|
856
|
-
processedUpdates.progress = 0;
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
} else if (
|
|
860
|
-
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
861
|
-
newStatusDef.category === 'not_started'
|
|
862
|
-
) {
|
|
863
|
-
// Moving from completed/canceled to not_started resets completion timestamp and progress
|
|
864
|
-
if (processedUpdates.actualEndDate === undefined) {
|
|
865
|
-
processedUpdates.actualEndDate = undefined;
|
|
866
|
-
}
|
|
867
|
-
if (processedUpdates.completedAt === undefined) {
|
|
868
|
-
processedUpdates.completedAt = undefined;
|
|
869
|
-
}
|
|
870
|
-
if (existing.progress === 100 && processedUpdates.progress === undefined) {
|
|
871
|
-
processedUpdates.progress = 0;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// 3. Entering completed or canceled
|
|
876
|
-
if (newStatusDef.category === 'completed') {
|
|
877
|
-
if (!processedUpdates.actualEndDate) {
|
|
878
|
-
processedUpdates.actualEndDate = now;
|
|
879
|
-
}
|
|
880
|
-
if (!processedUpdates.completedAt) {
|
|
881
|
-
processedUpdates.completedAt = now;
|
|
882
|
-
}
|
|
883
|
-
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
884
|
-
processedUpdates.progress = 100;
|
|
885
|
-
}
|
|
886
|
-
} else if (newStatusDef.category === 'canceled') {
|
|
887
|
-
if (!processedUpdates.actualEndDate) {
|
|
888
|
-
processedUpdates.actualEndDate = now;
|
|
889
|
-
}
|
|
890
|
-
if (processedUpdates.completedAt === undefined) {
|
|
891
|
-
processedUpdates.completedAt = undefined;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
|
|
897
|
-
if (effectiveCategory === 'in_progress') {
|
|
898
|
-
const wasBlocked = Boolean(existing.isBlocked);
|
|
899
|
-
const isNowBlocked = processedUpdates.isBlocked !== undefined
|
|
900
|
-
? Boolean(processedUpdates.isBlocked)
|
|
901
|
-
: wasBlocked;
|
|
902
|
-
|
|
903
|
-
if (!wasBlocked && isNowBlocked) {
|
|
904
|
-
if (!processedUpdates.blockedSince) {
|
|
905
|
-
processedUpdates.blockedSince = now;
|
|
906
|
-
}
|
|
907
|
-
} else if (wasBlocked && !isNowBlocked) {
|
|
908
|
-
const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
|
|
909
|
-
if (blockedSince) {
|
|
910
|
-
const startMs = new Date(blockedSince).getTime();
|
|
911
|
-
const endMs = new Date(now).getTime();
|
|
912
|
-
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
913
|
-
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
914
|
-
processedUpdates.blockedDurationSeconds =
|
|
915
|
-
(processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
|
|
916
|
-
}
|
|
917
|
-
processedUpdates.blockedSince = null;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
} else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
|
|
921
|
-
processedUpdates.blockedSince = null;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Mutual sync for manual duration/effort edits
|
|
925
|
-
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
|
|
926
|
-
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
927
|
-
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
928
|
-
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
929
|
-
}
|
|
930
|
-
} else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
|
|
931
|
-
processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
|
|
932
|
-
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
933
|
-
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
const updated = await this.store.updateTask(id, processedUpdates);
|
|
938
|
-
if (!updated) return null;
|
|
939
|
-
|
|
940
|
-
await this.plugins.runAfterTaskUpdate(updated, existing);
|
|
941
|
-
|
|
942
|
-
const isStatusChange = existing.status !== updated.status;
|
|
943
|
-
|
|
944
|
-
// Publish typed Domain Events
|
|
945
|
-
if (isStatusChange) {
|
|
946
|
-
const statusEvent: TaskStatusChangedEvent = {
|
|
947
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
948
|
-
name: 'task.status_changed',
|
|
949
|
-
aggregateId: updated.id,
|
|
950
|
-
aggregateType: 'Task',
|
|
951
|
-
occurredAt: now,
|
|
952
|
-
payload: {
|
|
953
|
-
task: updated,
|
|
954
|
-
previousStatus: existing.status,
|
|
955
|
-
newStatus: updated.status,
|
|
956
|
-
actorId,
|
|
957
|
-
...(actorName ? { actorName } : {}),
|
|
958
|
-
actor: actorObj
|
|
959
|
-
}
|
|
960
|
-
};
|
|
961
|
-
await this.events.publish(statusEvent);
|
|
962
|
-
|
|
963
|
-
const statusDef = resolveStatusDefinition(
|
|
964
|
-
updated.status,
|
|
965
|
-
project?.statusDefinitions || workflow?.statuses
|
|
966
|
-
);
|
|
967
|
-
if (statusDef.category === 'completed') {
|
|
968
|
-
await this.checkAndUnblockDownstreamTasks(updated);
|
|
969
|
-
}
|
|
970
|
-
} else {
|
|
971
|
-
const updateEvent: TaskUpdatedEvent = {
|
|
972
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
973
|
-
name: 'task.updated',
|
|
974
|
-
aggregateId: updated.id,
|
|
975
|
-
aggregateType: 'Task',
|
|
976
|
-
occurredAt: now,
|
|
977
|
-
payload: {
|
|
978
|
-
task: updated,
|
|
979
|
-
previous: existing,
|
|
980
|
-
actorId,
|
|
981
|
-
...(actorName ? { actorName } : {}),
|
|
982
|
-
actor: actorObj
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
await this.events.publish(updateEvent);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
const wasBlocked = Boolean(existing.isBlocked);
|
|
989
|
-
const isNowBlocked = Boolean(updated.isBlocked);
|
|
990
|
-
|
|
991
|
-
if (!wasBlocked && isNowBlocked) {
|
|
992
|
-
const blockedReason = updated.blockedReason ?? null;
|
|
993
|
-
const blockedEvent: TaskBlockedEvent = {
|
|
994
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
995
|
-
name: 'task.blocked',
|
|
996
|
-
aggregateId: updated.id,
|
|
997
|
-
aggregateType: 'Task',
|
|
998
|
-
occurredAt: now,
|
|
999
|
-
payload: {
|
|
1000
|
-
task: updated,
|
|
1001
|
-
reason: blockedReason,
|
|
1002
|
-
actorId,
|
|
1003
|
-
...(actorName ? { actorName } : {}),
|
|
1004
|
-
actor: actorObj
|
|
1005
|
-
}
|
|
1006
|
-
};
|
|
1007
|
-
await this.events.publish(blockedEvent);
|
|
1008
|
-
await this.store.logActivity({
|
|
1009
|
-
projectId: updated.projectId,
|
|
1010
|
-
taskId: updated.id,
|
|
1011
|
-
actorId,
|
|
1012
|
-
action: 'task.blocked',
|
|
1013
|
-
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
1014
|
-
});
|
|
1015
|
-
} else if (wasBlocked && !isNowBlocked) {
|
|
1016
|
-
const unblockedEvent: TaskUnblockedEvent = {
|
|
1017
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1018
|
-
name: 'task.unblocked',
|
|
1019
|
-
aggregateId: updated.id,
|
|
1020
|
-
aggregateType: 'Task',
|
|
1021
|
-
occurredAt: now,
|
|
1022
|
-
payload: {
|
|
1023
|
-
task: updated,
|
|
1024
|
-
actorId,
|
|
1025
|
-
...(actorName ? { actorName } : {}),
|
|
1026
|
-
actor: actorObj
|
|
1027
|
-
}
|
|
1028
|
-
};
|
|
1029
|
-
await this.events.publish(unblockedEvent);
|
|
1030
|
-
await this.store.logActivity({
|
|
1031
|
-
projectId: updated.projectId,
|
|
1032
|
-
taskId: updated.id,
|
|
1033
|
-
actorId,
|
|
1034
|
-
action: 'task.unblocked',
|
|
1035
|
-
details: { ...(actorName ? { actorName } : {}) }
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
await this.store.logActivity({
|
|
1040
|
-
projectId: updated.projectId,
|
|
1041
|
-
taskId: updated.id,
|
|
1042
|
-
actorId,
|
|
1043
|
-
action: isStatusChange ? 'task.status_changed' : 'task.updated',
|
|
1044
|
-
details: {
|
|
1045
|
-
fromStatus: existing.status,
|
|
1046
|
-
toStatus: updated.status,
|
|
1047
|
-
...(actorName ? { actorName } : {})
|
|
1048
|
-
}
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
return updated;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
private async checkAndUnblockDownstreamTasks(completedTask: Task): Promise<void> {
|
|
1056
|
-
try {
|
|
1057
|
-
const graph = await this.getTaskDependencyGraph(completedTask.id);
|
|
1058
|
-
if (!graph.downstreamTasks || graph.downstreamTasks.length === 0) return;
|
|
1059
|
-
|
|
1060
|
-
for (const downstream of graph.downstreamTasks) {
|
|
1061
|
-
const downstreamProject = await this.store.getProject(downstream.projectId);
|
|
1062
|
-
const downstreamWorkflow = await this.resolveProjectWorkflow(downstream.projectId);
|
|
1063
|
-
const downstreamStatusDefs = downstreamProject?.statusDefinitions || downstreamWorkflow?.statuses;
|
|
1064
|
-
const downstreamStatusDef = resolveStatusDefinition(downstream.status, downstreamStatusDefs);
|
|
1065
|
-
|
|
1066
|
-
if (downstreamStatusDef.category === 'completed' || downstreamStatusDef.category === 'canceled') {
|
|
1067
|
-
continue;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const downstreamGraph = await this.getTaskDependencyGraph(downstream.id);
|
|
1071
|
-
const allUpstreamsCompleted = downstreamGraph.upstreamTasks.every((up) => {
|
|
1072
|
-
const upDef = resolveStatusDefinition(up.status, downstreamStatusDefs);
|
|
1073
|
-
return upDef.category === 'completed';
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
if (allUpstreamsCompleted) {
|
|
1077
|
-
let updatedDownstream = downstream;
|
|
1078
|
-
const updates: Partial<Task> = {};
|
|
1079
|
-
|
|
1080
|
-
if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
|
|
1081
|
-
updates.isBlocked = false;
|
|
1082
|
-
updates.blockedReason = null;
|
|
1083
|
-
if (downstream.blockedSince) {
|
|
1084
|
-
const startMs = new Date(downstream.blockedSince).getTime();
|
|
1085
|
-
const endMs = new Date().getTime();
|
|
1086
|
-
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
1087
|
-
const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
1088
|
-
updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
|
|
1089
|
-
}
|
|
1090
|
-
updates.blockedSince = null;
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
if (Object.keys(updates).length > 0) {
|
|
1095
|
-
const res = await this.store.updateTask(downstream.id, updates);
|
|
1096
|
-
if (res) updatedDownstream = res;
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
const now = new Date().toISOString();
|
|
1100
|
-
const unblockedEvent: TaskUnblockedEvent = {
|
|
1101
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1102
|
-
name: 'task.unblocked',
|
|
1103
|
-
aggregateId: downstream.id,
|
|
1104
|
-
aggregateType: 'Task',
|
|
1105
|
-
occurredAt: now,
|
|
1106
|
-
payload: {
|
|
1107
|
-
task: updatedDownstream,
|
|
1108
|
-
upstreamTaskId: completedTask.id
|
|
1109
|
-
}
|
|
1110
|
-
};
|
|
1111
|
-
await this.events.publish(unblockedEvent);
|
|
1112
|
-
|
|
1113
|
-
await this.store.logActivity({
|
|
1114
|
-
projectId: downstream.projectId,
|
|
1115
|
-
taskId: downstream.id,
|
|
1116
|
-
actorId: this.actorIdOr('system'),
|
|
1117
|
-
action: 'task.unblocked',
|
|
1118
|
-
details: { unblockedByTaskId: completedTask.id }
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
} catch (err) {
|
|
1124
|
-
console.error(`[CriticalPathEngine] Error during auto-unblocking for task ${completedTask.id}:`, err);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* Deletes a task and everything that belongs to it: subtasks (or, with `subtasks: 'detach'`,
|
|
1130
|
-
* their parent link), dependencies, comments, attachments (including stored files) and time
|
|
1131
|
-
* entries. The activity log is kept as an audit trail.
|
|
1132
|
-
*/
|
|
1133
|
-
async deleteTask(id: string, options: { subtasks?: 'delete' | 'detach' } = {}): Promise<boolean> {
|
|
1134
|
-
const existing = await this.getTask(id);
|
|
1135
|
-
if (!existing) return false;
|
|
1136
|
-
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
1137
|
-
|
|
1138
|
-
await this.plugins.runBeforeTaskDelete(id, existing);
|
|
1139
|
-
await this.deleteTaskChildren(existing, options.subtasks ?? 'delete');
|
|
1140
|
-
const deleted = await this.store.deleteTask(id);
|
|
1141
|
-
|
|
1142
|
-
if (deleted) {
|
|
1143
|
-
await this.plugins.runAfterTaskDelete(id, existing);
|
|
1144
|
-
const now = new Date().toISOString();
|
|
1145
|
-
|
|
1146
|
-
const event: TaskDeletedEvent = {
|
|
1147
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1148
|
-
name: 'task.deleted',
|
|
1149
|
-
aggregateId: id,
|
|
1150
|
-
aggregateType: 'Task',
|
|
1151
|
-
occurredAt: now,
|
|
1152
|
-
payload: {
|
|
1153
|
-
taskId: id,
|
|
1154
|
-
projectId: existing.projectId,
|
|
1155
|
-
title: existing.title
|
|
1156
|
-
}
|
|
1157
|
-
};
|
|
1158
|
-
await this.events.publish(event);
|
|
1159
|
-
|
|
1160
|
-
await this.store.logActivity({
|
|
1161
|
-
projectId: existing.projectId,
|
|
1162
|
-
taskId: id,
|
|
1163
|
-
actorId: this.actorIdOr('system'),
|
|
1164
|
-
action: 'task.deleted',
|
|
1165
|
-
details: { title: existing.title }
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
return deleted;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/** Removes records owned by a task; the task's deletion was already authorized. */
|
|
1172
|
-
private async deleteTaskChildren(task: Task, subtasks: 'delete' | 'detach'): Promise<void> {
|
|
1173
|
-
const cascade = this.elevated();
|
|
1174
|
-
|
|
1175
|
-
const children = (await this.store.getTasks(task.projectId)).filter((t) => t.parentId === task.id);
|
|
1176
|
-
for (const child of children) {
|
|
1177
|
-
if (subtasks === 'delete') {
|
|
1178
|
-
await cascade.deleteTask(child.id, { subtasks });
|
|
1179
|
-
} else {
|
|
1180
|
-
await this.store.updateTask(child.id, { parentId: undefined });
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
for (const dependency of await this.store.getDependencies(task.id)) {
|
|
1185
|
-
await cascade.removeDependency(dependency.id);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
const comments = await this.store.getComments(task.id);
|
|
1189
|
-
const attachments = [
|
|
1190
|
-
...(await this.store.getAttachments({ taskId: task.id })),
|
|
1191
|
-
...(await Promise.all(comments.map((c) => this.store.getAttachments({ commentId: c.id })))).flat()
|
|
1192
|
-
];
|
|
1193
|
-
for (const attachmentId of new Set(attachments.map((a) => a.id))) {
|
|
1194
|
-
await cascade.deleteAttachment(attachmentId);
|
|
1195
|
-
}
|
|
1196
|
-
for (const comment of comments) {
|
|
1197
|
-
await cascade.deleteComment(comment.id);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
for (const entry of await this.store.getTimeEntries(task.id)) {
|
|
1201
|
-
await this.store.deleteTimeEntry(entry.id);
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
/** Removes a dependency between two tasks. Requires `task.update` on the dependent task's project. */
|
|
1206
|
-
async removeDependency(id: string): Promise<boolean> {
|
|
1207
|
-
const dependency = await this.store.getDependency(id);
|
|
1208
|
-
if (!dependency) return false;
|
|
1209
|
-
if (this.enforcing) {
|
|
1210
|
-
const task = await this.getTask(dependency.taskId);
|
|
1211
|
-
if (!task) return false;
|
|
1212
|
-
await this.requireProjectAccess('task.update', task.projectId);
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
const removed = await this.store.removeDependency(id);
|
|
1216
|
-
if (removed) {
|
|
1217
|
-
const event: TaskDependencyRemovedEvent = {
|
|
1218
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1219
|
-
name: 'dependency.removed',
|
|
1220
|
-
aggregateId: id,
|
|
1221
|
-
aggregateType: 'Dependency',
|
|
1222
|
-
occurredAt: new Date().toISOString(),
|
|
1223
|
-
payload: { dependency }
|
|
1224
|
-
};
|
|
1225
|
-
await this.events.publish(event);
|
|
1226
|
-
}
|
|
1227
|
-
return removed;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// --- Task Dependencies & Graph ---
|
|
1231
|
-
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
1232
|
-
if (this.enforcing) {
|
|
1233
|
-
const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
|
|
1234
|
-
if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
|
|
1235
|
-
if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
|
|
1236
|
-
await this.requireProjectAccess('task.update', task.projectId);
|
|
1237
|
-
}
|
|
1238
|
-
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
1239
|
-
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
1240
|
-
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
1241
|
-
if (cycleCheck.hasCycle) {
|
|
1242
|
-
throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
const created = await this.store.addDependency(dep);
|
|
1246
|
-
const now = new Date().toISOString();
|
|
1247
|
-
|
|
1248
|
-
const event: TaskDependencyAddedEvent = {
|
|
1249
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1250
|
-
name: 'dependency.added',
|
|
1251
|
-
aggregateId: created.id,
|
|
1252
|
-
aggregateType: 'Dependency',
|
|
1253
|
-
occurredAt: now,
|
|
1254
|
-
payload: { dependency: created }
|
|
1255
|
-
};
|
|
1256
|
-
await this.events.publish(event);
|
|
1257
|
-
|
|
1258
|
-
return created;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
/**
|
|
1262
|
-
* Collects every dependency edge reachable upstream from `startTaskId`. A new edge
|
|
1263
|
-
* `taskId -> dependsOnTaskId` creates a cycle exactly when `taskId` is reachable from
|
|
1264
|
-
* `dependsOnTaskId`, so this is the full set of edges the cycle check needs.
|
|
1265
|
-
*/
|
|
1266
|
-
private async collectUpstreamDependencies(startTaskId: string): Promise<TaskDependency[]> {
|
|
1267
|
-
const edges = new Map<string, TaskDependency>();
|
|
1268
|
-
const visited = new Set<string>();
|
|
1269
|
-
let frontier = [startTaskId];
|
|
1270
|
-
|
|
1271
|
-
while (frontier.length > 0) {
|
|
1272
|
-
const next: string[] = [];
|
|
1273
|
-
const results = await Promise.all(frontier.map((id) => this.store.getDependencies(id)));
|
|
1274
|
-
frontier.forEach((id) => visited.add(id));
|
|
1275
|
-
|
|
1276
|
-
for (const deps of results) {
|
|
1277
|
-
for (const d of deps) {
|
|
1278
|
-
// getDependencies returns edges in both directions; follow only outgoing ones.
|
|
1279
|
-
if (!visited.has(d.taskId)) continue;
|
|
1280
|
-
edges.set(d.id, d);
|
|
1281
|
-
if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
|
|
1282
|
-
next.push(d.dependsOnTaskId);
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
frontier = next;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
return Array.from(edges.values());
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
1293
|
-
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
1294
|
-
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
1295
|
-
}
|
|
1296
|
-
const dependencies = await this.store.getDependencies(taskId);
|
|
1297
|
-
const upstreamTaskIds = new Set<string>();
|
|
1298
|
-
const downstreamTaskIds = new Set<string>();
|
|
1299
|
-
|
|
1300
|
-
for (const dep of dependencies) {
|
|
1301
|
-
if (dep.taskId === taskId) {
|
|
1302
|
-
upstreamTaskIds.add(dep.dependsOnTaskId);
|
|
1303
|
-
} else if (dep.dependsOnTaskId === taskId) {
|
|
1304
|
-
downstreamTaskIds.add(dep.taskId);
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const upstreamTasks = (
|
|
1309
|
-
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
|
|
1310
|
-
).filter((t): t is Task => t !== null);
|
|
1311
|
-
|
|
1312
|
-
const downstreamTasks = (
|
|
1313
|
-
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
|
|
1314
|
-
).filter((t): t is Task => t !== null);
|
|
1315
|
-
|
|
1316
|
-
return {
|
|
1317
|
-
taskId,
|
|
1318
|
-
upstreamTasks,
|
|
1319
|
-
downstreamTasks,
|
|
1320
|
-
dependencies
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
async getTaskLifecycleState(
|
|
1325
|
-
taskId: string,
|
|
1326
|
-
options?: { referenceDate?: Date; stalledThresholdDays?: number }
|
|
1327
|
-
): Promise<TaskLifecycleState | null> {
|
|
1328
|
-
const task = await this.getTask(taskId);
|
|
1329
|
-
if (!task) return null;
|
|
1330
|
-
const project = await this.getProject(task.projectId);
|
|
1331
|
-
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
1332
|
-
const statusDefs = project?.statusDefinitions || workflow?.statuses;
|
|
1333
|
-
|
|
1334
|
-
const graph = await this.getTaskDependencyGraph(taskId);
|
|
1335
|
-
const upstreamTasks = graph.upstreamTasks;
|
|
1336
|
-
|
|
1337
|
-
return deriveTaskLifecycleState(task, {
|
|
1338
|
-
customDefinitions: statusDefs,
|
|
1339
|
-
referenceDate: options?.referenceDate,
|
|
1340
|
-
stalledThresholdDays: options?.stalledThresholdDays,
|
|
1341
|
-
upstreamTasks
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
// --- Time Tracking ---
|
|
1346
|
-
async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
|
|
1347
|
-
if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
|
|
1348
|
-
throw new ValidationError('Logged hours must be a positive number.');
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
const task = await this.getTask(entry.taskId);
|
|
1352
|
-
if (this.enforcing) {
|
|
1353
|
-
if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
|
|
1354
|
-
await this.requireProjectAccess('time.log', task.projectId);
|
|
1355
|
-
}
|
|
1356
|
-
if (task) {
|
|
1357
|
-
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
1358
|
-
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
1359
|
-
const newBillableHours = entry.isBillable !== false ? (task.billableHours || 0) + entry.hours : task.billableHours;
|
|
1360
|
-
await this.store.updateTask(task.id, {
|
|
1361
|
-
loggedHours: newLoggedHours,
|
|
1362
|
-
actualHours: newActualHours,
|
|
1363
|
-
billableHours: newBillableHours
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
|
|
1368
|
-
...entry,
|
|
1369
|
-
userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
|
|
1370
|
-
loggedAt: entry.loggedAt
|
|
1371
|
-
};
|
|
1372
|
-
|
|
1373
|
-
const created = await this.store.logTime(fullEntry);
|
|
1374
|
-
const now = new Date().toISOString();
|
|
1375
|
-
|
|
1376
|
-
const event: TimeLoggedEvent = {
|
|
1377
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1378
|
-
name: 'time.logged',
|
|
1379
|
-
aggregateId: entry.taskId,
|
|
1380
|
-
aggregateType: 'Task',
|
|
1381
|
-
occurredAt: now,
|
|
1382
|
-
payload: { taskId: entry.taskId, timeEntry: created }
|
|
1383
|
-
};
|
|
1384
|
-
await this.events.publish(event);
|
|
1385
|
-
|
|
1386
|
-
return created;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
// --- Comments ---
|
|
1390
|
-
async getComments(taskId: string): Promise<Comment[]> {
|
|
1391
|
-
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1392
|
-
return this.store.getComments(taskId);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
async getComment(id: string): Promise<Comment | null> {
|
|
1396
|
-
const comment = await this.store.getComment(id);
|
|
1397
|
-
if (!comment) return null;
|
|
1398
|
-
return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
|
|
1402
|
-
const activities = await this.store.getActivities(filter);
|
|
1403
|
-
return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
|
|
1407
|
-
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1408
|
-
return this.store.getTimeEntries(taskId);
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
async addComment(
|
|
1412
|
-
input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
|
|
1413
|
-
): Promise<Comment> {
|
|
1414
|
-
if (this.enforcing) {
|
|
1415
|
-
const task = await this.getTask(input.taskId);
|
|
1416
|
-
if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
|
|
1417
|
-
await this.requireProjectAccess('comment.create', task.projectId);
|
|
1418
|
-
}
|
|
1419
|
-
const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1420
|
-
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
1421
|
-
: { ...input, authorId: input.authorId ?? 'system' };
|
|
1422
|
-
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
1423
|
-
const created = await this.store.addComment({ ...comment, mentions });
|
|
1424
|
-
const now = new Date().toISOString();
|
|
1425
|
-
|
|
1426
|
-
const event: CommentAddedEvent = {
|
|
1427
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1428
|
-
name: 'comment.created',
|
|
1429
|
-
aggregateId: created.id,
|
|
1430
|
-
aggregateType: 'Comment',
|
|
1431
|
-
occurredAt: now,
|
|
1432
|
-
payload: { taskId: comment.taskId, comment: created }
|
|
1433
|
-
};
|
|
1434
|
-
await this.events.publish(event);
|
|
1435
|
-
|
|
1436
|
-
return created;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
1440
|
-
const existing = await this.getComment(id);
|
|
1441
|
-
if (!existing) return null;
|
|
1442
|
-
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1443
|
-
type: 'comment',
|
|
1444
|
-
ownerId: existing.authorId
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
1448
|
-
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
1449
|
-
|
|
1450
|
-
const updated = await this.store.updateComment(id, toUpdate);
|
|
1451
|
-
if (!updated) return null;
|
|
1452
|
-
|
|
1453
|
-
const now = new Date().toISOString();
|
|
1454
|
-
const event: CommentUpdatedEvent = {
|
|
1455
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1456
|
-
name: 'comment.updated',
|
|
1457
|
-
aggregateId: updated.id,
|
|
1458
|
-
aggregateType: 'Comment',
|
|
1459
|
-
occurredAt: now,
|
|
1460
|
-
payload: { comment: updated, previous: existing }
|
|
1461
|
-
};
|
|
1462
|
-
await this.events.publish(event);
|
|
1463
|
-
|
|
1464
|
-
return updated;
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
async deleteComment(id: string): Promise<boolean> {
|
|
1468
|
-
const existing = await this.getComment(id);
|
|
1469
|
-
if (!existing) return false;
|
|
1470
|
-
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1471
|
-
type: 'comment',
|
|
1472
|
-
ownerId: existing.authorId
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
const deleted = await this.store.deleteComment(id);
|
|
1476
|
-
if (deleted) {
|
|
1477
|
-
const now = new Date().toISOString();
|
|
1478
|
-
const event: CommentDeletedEvent = {
|
|
1479
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1480
|
-
name: 'comment.deleted',
|
|
1481
|
-
aggregateId: id,
|
|
1482
|
-
aggregateType: 'Comment',
|
|
1483
|
-
occurredAt: now,
|
|
1484
|
-
payload: { taskId: existing.taskId, commentId: id }
|
|
1485
|
-
};
|
|
1486
|
-
await this.events.publish(event);
|
|
1487
|
-
|
|
1488
|
-
}
|
|
1489
|
-
return deleted;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1493
|
-
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1494
|
-
const existing = await this.getComment(commentId);
|
|
1495
|
-
if (!existing) return null;
|
|
1496
|
-
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1497
|
-
|
|
1498
|
-
let updated: Comment | null;
|
|
1499
|
-
if (this.store.addReaction) {
|
|
1500
|
-
updated = await this.store.addReaction(commentId, reaction);
|
|
1501
|
-
} else {
|
|
1502
|
-
const reactions = existing.reactions ? [...existing.reactions] : [];
|
|
1503
|
-
const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
|
|
1504
|
-
if (!alreadyExists) {
|
|
1505
|
-
reactions.push({
|
|
1506
|
-
emoji: reaction.emoji,
|
|
1507
|
-
userId: reaction.userId,
|
|
1508
|
-
createdAt: new Date().toISOString()
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
updated = await this.store.updateComment(commentId, { reactions });
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
if (!updated) return null;
|
|
1515
|
-
|
|
1516
|
-
const now = new Date().toISOString();
|
|
1517
|
-
const event: CommentReactionAddedEvent = {
|
|
1518
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1519
|
-
name: 'comment.reaction.added',
|
|
1520
|
-
aggregateId: commentId,
|
|
1521
|
-
aggregateType: 'Comment',
|
|
1522
|
-
occurredAt: now,
|
|
1523
|
-
payload: {
|
|
1524
|
-
commentId,
|
|
1525
|
-
reaction: {
|
|
1526
|
-
emoji: reaction.emoji,
|
|
1527
|
-
userId: reaction.userId,
|
|
1528
|
-
createdAt: now
|
|
1529
|
-
},
|
|
1530
|
-
comment: updated
|
|
1531
|
-
}
|
|
1532
|
-
};
|
|
1533
|
-
await this.events.publish(event);
|
|
1534
|
-
|
|
1535
|
-
return updated;
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1539
|
-
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1540
|
-
const existing = await this.getComment(commentId);
|
|
1541
|
-
if (!existing) return null;
|
|
1542
|
-
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1543
|
-
|
|
1544
|
-
let updated: Comment | null;
|
|
1545
|
-
if (this.store.removeReaction) {
|
|
1546
|
-
updated = await this.store.removeReaction(commentId, reaction);
|
|
1547
|
-
} else {
|
|
1548
|
-
const reactions = (existing.reactions || []).filter(
|
|
1549
|
-
(r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
|
|
1550
|
-
);
|
|
1551
|
-
updated = await this.store.updateComment(commentId, { reactions });
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
if (!updated) return null;
|
|
1555
|
-
|
|
1556
|
-
const now = new Date().toISOString();
|
|
1557
|
-
const event: CommentReactionRemovedEvent = {
|
|
1558
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1559
|
-
name: 'comment.reaction.removed',
|
|
1560
|
-
aggregateId: commentId,
|
|
1561
|
-
aggregateType: 'Comment',
|
|
1562
|
-
occurredAt: now,
|
|
1563
|
-
payload: { commentId, reaction, comment: updated }
|
|
1564
|
-
};
|
|
1565
|
-
await this.events.publish(event);
|
|
1566
|
-
|
|
1567
|
-
return updated;
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
// --- Attachments ---
|
|
1571
|
-
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
|
|
1572
|
-
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
|
|
1573
|
-
this.projectIdOfAttachment(a)
|
|
1574
|
-
);
|
|
1575
|
-
if (filter?.artifactType) {
|
|
1576
|
-
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1577
|
-
}
|
|
1578
|
-
return attachments;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
async getAttachment(id: string): Promise<Attachment | null> {
|
|
1582
|
-
const attachment = await this.store.getAttachment(id);
|
|
1583
|
-
if (!attachment) return null;
|
|
1584
|
-
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
|
|
1588
|
-
if (!this.enforcing) return;
|
|
1589
|
-
const projectId = await this.projectIdOfAttachment(link);
|
|
1590
|
-
if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1591
|
-
await this.requireProjectAccess('attachment.create', projectId);
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
async createAttachment(
|
|
1595
|
-
input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
|
|
1596
|
-
): Promise<Attachment> {
|
|
1597
|
-
await this.requireAttachmentCreate(input);
|
|
1598
|
-
if (this.actor) await this.assertStorageKeyBelongs(input);
|
|
1599
|
-
const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1600
|
-
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1601
|
-
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
1602
|
-
validateAttachmentUrl(attachment.url);
|
|
1603
|
-
const created = await this.store.createAttachment(attachment);
|
|
1604
|
-
const now = new Date().toISOString();
|
|
1605
|
-
|
|
1606
|
-
const event: AttachmentCreatedEvent = {
|
|
1607
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1608
|
-
name: 'attachment.created',
|
|
1609
|
-
aggregateId: created.id,
|
|
1610
|
-
aggregateType: 'Attachment',
|
|
1611
|
-
occurredAt: now,
|
|
1612
|
-
payload: { attachment: created }
|
|
1613
|
-
};
|
|
1614
|
-
await this.events.publish(event);
|
|
1615
|
-
|
|
1616
|
-
return created;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
async deleteAttachment(id: string): Promise<boolean> {
|
|
1620
|
-
const existing = await this.getAttachment(id);
|
|
1621
|
-
if (!existing) return false;
|
|
1622
|
-
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1623
|
-
type: 'attachment',
|
|
1624
|
-
ownerId: existing.uploaderId
|
|
1625
|
-
});
|
|
1626
|
-
|
|
1627
|
-
if (existing.storageKey && this.fileStorage) {
|
|
1628
|
-
try {
|
|
1629
|
-
await this.fileStorage.delete(existing.storageKey);
|
|
1630
|
-
} catch {
|
|
1631
|
-
// Silently continue if underlying file is already gone
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
const deleted = await this.store.deleteAttachment(id);
|
|
1636
|
-
if (deleted) {
|
|
1637
|
-
const now = new Date().toISOString();
|
|
1638
|
-
const event: AttachmentDeletedEvent = {
|
|
1639
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1640
|
-
name: 'attachment.deleted',
|
|
1641
|
-
aggregateId: id,
|
|
1642
|
-
aggregateType: 'Attachment',
|
|
1643
|
-
occurredAt: now,
|
|
1644
|
-
payload: {
|
|
1645
|
-
attachmentId: id,
|
|
1646
|
-
storageKey: existing.storageKey,
|
|
1647
|
-
url: existing.url,
|
|
1648
|
-
projectId: await this.projectIdOfAttachment(existing)
|
|
1649
|
-
}
|
|
1650
|
-
};
|
|
1651
|
-
await this.events.publish(event);
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
return deleted;
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
async uploadAttachmentFile(
|
|
1658
|
-
input: UploadFileInput & {
|
|
1659
|
-
taskId?: string;
|
|
1660
|
-
projectId?: string;
|
|
1661
|
-
commentId?: string;
|
|
1662
|
-
uploaderId?: string;
|
|
1663
|
-
uploaderType?: 'user' | 'agent' | 'system';
|
|
1664
|
-
artifactType?: 'plan' | 'spec' | 'deliverable' | 'review' | 'general';
|
|
1665
|
-
metadata?: Record<string, unknown>;
|
|
1666
|
-
}
|
|
1667
|
-
): Promise<Attachment> {
|
|
1668
|
-
if (!this.fileStorage) {
|
|
1669
|
-
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1670
|
-
}
|
|
1671
|
-
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1672
|
-
await this.requireAttachmentCreate(input);
|
|
1673
|
-
// Files always live under their project's prefix; only trusted base-engine calls may choose another.
|
|
1674
|
-
const projectId = await this.projectIdOfAttachment(input);
|
|
1675
|
-
if (this.actor && !projectId) {
|
|
1676
|
-
throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
const uploadResult = await this.fileStorage.upload({
|
|
1680
|
-
filename: input.filename,
|
|
1681
|
-
data: input.data,
|
|
1682
|
-
mimeType: input.mimeType,
|
|
1683
|
-
pathPrefix: projectId && (this.actor || !input.pathPrefix) ? `projects/${projectId}` : input.pathPrefix
|
|
1684
|
-
});
|
|
1685
|
-
|
|
1686
|
-
return this.createAttachment({
|
|
1687
|
-
taskId: input.taskId,
|
|
1688
|
-
projectId: input.projectId,
|
|
1689
|
-
commentId: input.commentId,
|
|
1690
|
-
uploaderId: input.uploaderId,
|
|
1691
|
-
uploaderType: input.uploaderType || 'user',
|
|
1692
|
-
filename: input.filename,
|
|
1693
|
-
mimeType: uploadResult.mimeType,
|
|
1694
|
-
sizeBytes: uploadResult.sizeBytes,
|
|
1695
|
-
url: uploadResult.url,
|
|
1696
|
-
storageKey: uploadResult.storageKey,
|
|
1697
|
-
artifactType: input.artifactType,
|
|
1698
|
-
metadata: input.metadata
|
|
1699
|
-
});
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
async readAttachmentText(id: string): Promise<string> {
|
|
1703
|
-
const attachment = await this.getAttachment(id);
|
|
1704
|
-
if (!attachment) {
|
|
1705
|
-
throw new NotFoundError(`Attachment with id "${id}" not found.`);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
if (attachment.storageKey && this.fileStorage && typeof this.fileStorage.download === 'function') {
|
|
1709
|
-
const bytes = await this.fileStorage.download(attachment.storageKey);
|
|
1710
|
-
return new TextDecoder().decode(bytes);
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
if (attachment.url) {
|
|
1714
|
-
if (attachment.url.startsWith('data:')) {
|
|
1715
|
-
const commaIndex = attachment.url.indexOf(',');
|
|
1716
|
-
const encoded = commaIndex !== -1 ? attachment.url.substring(commaIndex + 1) : attachment.url;
|
|
1717
|
-
if (typeof Buffer !== 'undefined') {
|
|
1718
|
-
return Buffer.from(encoded, 'base64').toString('utf-8');
|
|
1719
|
-
}
|
|
1720
|
-
if (typeof atob === 'function') {
|
|
1721
|
-
return atob(encoded);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
const res = await fetch(attachment.url);
|
|
1726
|
-
if (!res.ok) {
|
|
1727
|
-
throw new Error(`Failed to fetch attachment from URL "${attachment.url}": ${res.statusText}`);
|
|
1728
|
-
}
|
|
1729
|
-
return await res.text();
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
/**
|
|
1736
|
-
* Returns a URL the client can upload a file to directly (e.g. a signed S3 PUT). The engine
|
|
1737
|
-
* chooses the storage key under `projects/<projectId>/`; register the uploaded file afterwards
|
|
1738
|
-
* with `createAttachment({ projectId, storageKey, ... })` using the returned `storageKey`.
|
|
1739
|
-
*/
|
|
1740
|
-
async getPresignedAttachmentUploadUrl(options: {
|
|
1741
|
-
projectId: string;
|
|
1742
|
-
filename: string;
|
|
1743
|
-
contentType?: string;
|
|
1744
|
-
expiresInSeconds?: number;
|
|
1745
|
-
}): Promise<PresignedUploadResult> {
|
|
1746
|
-
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1747
|
-
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1748
|
-
}
|
|
1749
|
-
if (!options.projectId) throw new ValidationError('projectId is required for presigned uploads.');
|
|
1750
|
-
if (this.enforcing) {
|
|
1751
|
-
await this.requireProjectAccess('attachment.create', options.projectId);
|
|
1752
|
-
} else if (!(await this.store.getProject(options.projectId))) {
|
|
1753
|
-
throw new NotFoundError(`Project "${options.projectId}" not found.`);
|
|
1754
|
-
}
|
|
1755
|
-
return this.fileStorage.getPresignedUploadUrl({
|
|
1756
|
-
storageKey: buildStorageKey(`projects/${options.projectId}`, options.filename),
|
|
1757
|
-
contentType: options.contentType,
|
|
1758
|
-
expiresInSeconds: options.expiresInSeconds
|
|
1759
|
-
});
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
/**
|
|
1763
|
-
* A caller-supplied `storageKey` must live under the attachment's project prefix, because
|
|
1764
|
-
* deleting the attachment deletes that file: otherwise anyone could register, then delete,
|
|
1765
|
-
* another project's files.
|
|
1766
|
-
*/
|
|
1767
|
-
private async assertStorageKeyBelongs(link: { projectId?: string; taskId?: string; commentId?: string; storageKey?: string }): Promise<void> {
|
|
1768
|
-
if (!link.storageKey) return;
|
|
1769
|
-
const projectId = await this.projectIdOfAttachment(link);
|
|
1770
|
-
const prefix = `projects/${projectId}/`;
|
|
1771
|
-
const segments = link.storageKey.split('/');
|
|
1772
|
-
if (!projectId || !link.storageKey.startsWith(prefix) || segments.some((s) => s === '..' || s === '.')) {
|
|
1773
|
-
throw new ValidationError(`storageKey must be under "${projectId ? prefix : 'projects/<projectId>/'}" for this attachment.`);
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
// --- Teams ---
|
|
1778
|
-
async getTeams(): Promise<Team[]> {
|
|
1779
|
-
const teams = await this.store.getTeams();
|
|
1780
|
-
return teams.filter((t) => this.inActorTenant(t));
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
async getTeam(id: string): Promise<Team | null> {
|
|
1784
|
-
const team = await this.store.getTeam(id);
|
|
1785
|
-
return team && this.inActorTenant(team) ? team : null;
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
1789
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
1790
|
-
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1791
|
-
const now = new Date().toISOString();
|
|
1792
|
-
|
|
1793
|
-
const event: TeamCreatedEvent = {
|
|
1794
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1795
|
-
name: 'team.created',
|
|
1796
|
-
aggregateId: created.id,
|
|
1797
|
-
aggregateType: 'Team',
|
|
1798
|
-
occurredAt: now,
|
|
1799
|
-
payload: { team: created }
|
|
1800
|
-
};
|
|
1801
|
-
await this.events.publish(event);
|
|
1802
|
-
|
|
1803
|
-
return created;
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
1807
|
-
const existing = await this.getTeam(id);
|
|
1808
|
-
if (!existing) return null;
|
|
1809
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
1810
|
-
const updated = await this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1811
|
-
if (updated) {
|
|
1812
|
-
await this.publishEvent<TeamUpdatedEvent>({
|
|
1813
|
-
name: 'team.updated',
|
|
1814
|
-
aggregateId: id,
|
|
1815
|
-
aggregateType: 'Team',
|
|
1816
|
-
payload: { team: updated, previous: existing }
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
return updated;
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
async deleteTeam(id: string): Promise<boolean> {
|
|
1823
|
-
const existing = await this.getTeam(id);
|
|
1824
|
-
if (!existing) return false;
|
|
1825
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
1826
|
-
const deleted = await this.store.deleteTeam(id);
|
|
1827
|
-
if (deleted) {
|
|
1828
|
-
await this.publishEvent<TeamDeletedEvent>({
|
|
1829
|
-
name: 'team.deleted',
|
|
1830
|
-
aggregateId: id,
|
|
1831
|
-
aggregateType: 'Team',
|
|
1832
|
-
payload: { teamId: id, name: existing.name, tenantId: existing.tenantId }
|
|
1833
|
-
});
|
|
1834
|
-
}
|
|
1835
|
-
return deleted;
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
// --- Containers ---
|
|
1839
|
-
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
1840
|
-
if (!(await this.canReadProject(projectId))) return [];
|
|
1841
|
-
return this.store.getContainers(projectId);
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
1845
|
-
const container = await this.store.getContainer(id);
|
|
1846
|
-
if (!container) return null;
|
|
1847
|
-
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
1851
|
-
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1852
|
-
const created = await this.store.createContainer(container);
|
|
1853
|
-
const now = new Date().toISOString();
|
|
1854
|
-
|
|
1855
|
-
const event: ContainerCreatedEvent = {
|
|
1856
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1857
|
-
name: 'container.created',
|
|
1858
|
-
aggregateId: created.id,
|
|
1859
|
-
aggregateType: 'Container',
|
|
1860
|
-
occurredAt: now,
|
|
1861
|
-
payload: { container: created }
|
|
1862
|
-
};
|
|
1863
|
-
await this.events.publish(event);
|
|
1864
|
-
|
|
1865
|
-
return created;
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
|
|
1869
|
-
const existing = await this.getContainer(id);
|
|
1870
|
-
if (!existing) return null;
|
|
1871
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1872
|
-
const updated = await this.store.updateContainer(id, updates);
|
|
1873
|
-
if (updated) {
|
|
1874
|
-
await this.publishEvent<ContainerUpdatedEvent>({
|
|
1875
|
-
name: 'container.updated',
|
|
1876
|
-
aggregateId: id,
|
|
1877
|
-
aggregateType: 'Container',
|
|
1878
|
-
payload: { container: updated, previous: existing }
|
|
1879
|
-
});
|
|
1880
|
-
}
|
|
1881
|
-
return updated;
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
/** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
|
|
1885
|
-
async deleteContainer(id: string): Promise<boolean> {
|
|
1886
|
-
const existing = await this.getContainer(id);
|
|
1887
|
-
if (!existing) return false;
|
|
1888
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1889
|
-
await this.clearTaskReferences(existing.projectId, 'containerId', id);
|
|
1890
|
-
for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
|
|
1891
|
-
await this.store.updateContainer(child.id, { parentId: undefined });
|
|
1892
|
-
}
|
|
1893
|
-
const deleted = await this.store.deleteContainer(id);
|
|
1894
|
-
if (deleted) {
|
|
1895
|
-
await this.publishEvent<ContainerDeletedEvent>({
|
|
1896
|
-
name: 'container.deleted',
|
|
1897
|
-
aggregateId: id,
|
|
1898
|
-
aggregateType: 'Container',
|
|
1899
|
-
payload: { containerId: id, projectId: existing.projectId, name: existing.name }
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
return deleted;
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
|
|
1906
|
-
private async clearTaskReferences(
|
|
1907
|
-
projectId: string,
|
|
1908
|
-
field: 'containerId' | 'iterationId' | 'deliverableId',
|
|
1909
|
-
id: string
|
|
1910
|
-
): Promise<void> {
|
|
1911
|
-
for (const task of await this.store.getTasks(projectId)) {
|
|
1912
|
-
if (task[field] === id) await this.store.updateTask(task.id, { [field]: undefined });
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
// --- Deliverables ---
|
|
1917
|
-
async getDeliverables(projectId: string): Promise<Deliverable[]> {
|
|
1918
|
-
if (!(await this.canReadProject(projectId))) return [];
|
|
1919
|
-
return this.store.getDeliverables(projectId);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
async getDeliverable(id: string): Promise<Deliverable | null> {
|
|
1923
|
-
const deliverable = await this.store.getDeliverable(id);
|
|
1924
|
-
if (!deliverable) return null;
|
|
1925
|
-
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
|
|
1929
|
-
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1930
|
-
const project = await this.store.getProject(input.projectId);
|
|
1931
|
-
if (project?.customFieldDefinitions && input.customFields) {
|
|
1932
|
-
validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
const created = await this.store.createDeliverable({
|
|
1936
|
-
...input,
|
|
1937
|
-
status: input.status || 'planned',
|
|
1938
|
-
outputUrls: input.outputUrls ?? [],
|
|
1939
|
-
customFields: input.customFields ?? {}
|
|
1940
|
-
});
|
|
1941
|
-
const now = new Date().toISOString();
|
|
1942
|
-
|
|
1943
|
-
const event: DeliverableCreatedEvent = {
|
|
1944
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1945
|
-
name: 'deliverable.created',
|
|
1946
|
-
aggregateId: created.id,
|
|
1947
|
-
aggregateType: 'Deliverable',
|
|
1948
|
-
occurredAt: now,
|
|
1949
|
-
payload: { deliverable: created }
|
|
1950
|
-
};
|
|
1951
|
-
await this.events.publish(event);
|
|
1952
|
-
|
|
1953
|
-
await this.store.logActivity({
|
|
1954
|
-
projectId: created.projectId,
|
|
1955
|
-
actorId: this.actorIdOr('system'),
|
|
1956
|
-
action: 'deliverable.created',
|
|
1957
|
-
details: { title: created.title, format: created.format }
|
|
1958
|
-
});
|
|
1959
|
-
return created;
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
|
|
1963
|
-
const existing = await this.getDeliverable(id);
|
|
1964
|
-
if (!existing) return null;
|
|
1965
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1966
|
-
|
|
1967
|
-
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1968
|
-
updates.deliveredAt = new Date().toISOString();
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
const updated = await this.store.updateDeliverable(id, updates);
|
|
1972
|
-
if (updated) {
|
|
1973
|
-
const now = new Date().toISOString();
|
|
1974
|
-
|
|
1975
|
-
if (updates.status && updates.status !== existing.status) {
|
|
1976
|
-
const statusEvent: DeliverableStatusChangedEvent = {
|
|
1977
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1978
|
-
name: 'deliverable.status_changed',
|
|
1979
|
-
aggregateId: updated.id,
|
|
1980
|
-
aggregateType: 'Deliverable',
|
|
1981
|
-
occurredAt: now,
|
|
1982
|
-
payload: {
|
|
1983
|
-
deliverable: updated,
|
|
1984
|
-
previousStatus: existing.status,
|
|
1985
|
-
newStatus: updated.status
|
|
1986
|
-
}
|
|
1987
|
-
};
|
|
1988
|
-
await this.events.publish(statusEvent);
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
const updateEvent: DeliverableUpdatedEvent = {
|
|
1992
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
1993
|
-
name: 'deliverable.updated',
|
|
1994
|
-
aggregateId: updated.id,
|
|
1995
|
-
aggregateType: 'Deliverable',
|
|
1996
|
-
occurredAt: now,
|
|
1997
|
-
payload: { deliverable: updated, previous: existing }
|
|
1998
|
-
};
|
|
1999
|
-
await this.events.publish(updateEvent);
|
|
2000
|
-
|
|
2001
|
-
await this.store.logActivity({
|
|
2002
|
-
projectId: updated.projectId,
|
|
2003
|
-
actorId: this.actorIdOr('system'),
|
|
2004
|
-
action: 'deliverable.updated',
|
|
2005
|
-
details: { title: updated.title, status: updated.status }
|
|
2006
|
-
});
|
|
2007
|
-
}
|
|
2008
|
-
return updated;
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
async deleteDeliverable(id: string): Promise<boolean> {
|
|
2012
|
-
const existing = await this.getDeliverable(id);
|
|
2013
|
-
if (!existing) return false;
|
|
2014
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
2015
|
-
await this.clearTaskReferences(existing.projectId, 'deliverableId', id);
|
|
2016
|
-
|
|
2017
|
-
const deleted = await this.store.deleteDeliverable(id);
|
|
2018
|
-
if (deleted) {
|
|
2019
|
-
const now = new Date().toISOString();
|
|
2020
|
-
const event: DeliverableDeletedEvent = {
|
|
2021
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
2022
|
-
name: 'deliverable.deleted',
|
|
2023
|
-
aggregateId: id,
|
|
2024
|
-
aggregateType: 'Deliverable',
|
|
2025
|
-
occurredAt: now,
|
|
2026
|
-
payload: { deliverableId: id, projectId: existing.projectId }
|
|
2027
|
-
};
|
|
2028
|
-
await this.events.publish(event);
|
|
2029
|
-
|
|
2030
|
-
await this.store.logActivity({
|
|
2031
|
-
projectId: existing.projectId,
|
|
2032
|
-
actorId: this.actorIdOr('system'),
|
|
2033
|
-
action: 'deliverable.deleted',
|
|
2034
|
-
details: { title: existing.title }
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
return deleted;
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
|
|
2041
|
-
const deliverable = await this.getDeliverable(deliverableId);
|
|
2042
|
-
if (!deliverable) return null;
|
|
2043
|
-
|
|
2044
|
-
const project = await this.store.getProject(deliverable.projectId);
|
|
2045
|
-
const workflow = await this.resolveProjectWorkflow(deliverable.projectId);
|
|
2046
|
-
const allTasks = await this.store.getTasks(deliverable.projectId);
|
|
2047
|
-
const tasks = allTasks.filter((t) => t.deliverableId === deliverableId);
|
|
2048
|
-
|
|
2049
|
-
const totalTasks = tasks.length;
|
|
2050
|
-
let completedTasks = 0;
|
|
2051
|
-
let activeTasks = 0;
|
|
2052
|
-
let estimatedHours = 0;
|
|
2053
|
-
let loggedHours = 0;
|
|
2054
|
-
let totalProgress = 0;
|
|
2055
|
-
|
|
2056
|
-
for (const task of tasks) {
|
|
2057
|
-
const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
|
|
2058
|
-
if (statusDef.category === 'completed') {
|
|
2059
|
-
completedTasks++;
|
|
2060
|
-
} else if (statusDef.category === 'in_progress') {
|
|
2061
|
-
activeTasks++;
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
if (task.estimatedHours) estimatedHours += task.estimatedHours;
|
|
2065
|
-
if (task.loggedHours) loggedHours += task.loggedHours;
|
|
2066
|
-
|
|
2067
|
-
if (typeof task.progress === 'number') {
|
|
2068
|
-
totalProgress += task.progress;
|
|
2069
|
-
} else {
|
|
2070
|
-
totalProgress += statusDef.category === 'completed' ? 100 : 0;
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
|
|
2075
|
-
|
|
2076
|
-
return {
|
|
2077
|
-
deliverable,
|
|
2078
|
-
totalTasks,
|
|
2079
|
-
completedTasks,
|
|
2080
|
-
activeTasks,
|
|
2081
|
-
progressPercentage,
|
|
2082
|
-
estimatedHours,
|
|
2083
|
-
loggedHours
|
|
2084
|
-
};
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
// --- Iterations ---
|
|
2088
|
-
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
2089
|
-
if (!(await this.canReadProject(projectId))) return [];
|
|
2090
|
-
return this.store.getIterations(projectId);
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
async getIteration(id: string): Promise<Iteration | null> {
|
|
2094
|
-
const iteration = await this.store.getIteration(id);
|
|
2095
|
-
if (!iteration) return null;
|
|
2096
|
-
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
2100
|
-
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
2101
|
-
const created = await this.store.createIteration(iteration);
|
|
2102
|
-
await this.publishEvent<IterationCreatedEvent>({
|
|
2103
|
-
name: 'iteration.created',
|
|
2104
|
-
aggregateId: created.id,
|
|
2105
|
-
aggregateType: 'Iteration',
|
|
2106
|
-
payload: { iteration: created }
|
|
2107
|
-
});
|
|
2108
|
-
if (created.status === 'active') {
|
|
2109
|
-
const now = new Date().toISOString();
|
|
2110
|
-
const event: IterationStartedEvent = {
|
|
2111
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
2112
|
-
name: 'iteration.started',
|
|
2113
|
-
aggregateId: created.id,
|
|
2114
|
-
aggregateType: 'Iteration',
|
|
2115
|
-
occurredAt: now,
|
|
2116
|
-
payload: { iteration: created }
|
|
2117
|
-
};
|
|
2118
|
-
await this.events.publish(event);
|
|
2119
|
-
}
|
|
2120
|
-
return created;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
2124
|
-
const existing = await this.getIteration(id);
|
|
2125
|
-
if (!existing) return null;
|
|
2126
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
2127
|
-
|
|
2128
|
-
const updated = await this.store.updateIteration(id, updates);
|
|
2129
|
-
if (updated) {
|
|
2130
|
-
await this.publishEvent<IterationUpdatedEvent>({
|
|
2131
|
-
name: 'iteration.updated',
|
|
2132
|
-
aggregateId: id,
|
|
2133
|
-
aggregateType: 'Iteration',
|
|
2134
|
-
payload: { iteration: updated, previous: existing }
|
|
2135
|
-
});
|
|
2136
|
-
const now = new Date().toISOString();
|
|
2137
|
-
if (existing.status !== 'active' && updated.status === 'active') {
|
|
2138
|
-
const event: IterationStartedEvent = {
|
|
2139
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
2140
|
-
name: 'iteration.started',
|
|
2141
|
-
aggregateId: updated.id,
|
|
2142
|
-
aggregateType: 'Iteration',
|
|
2143
|
-
occurredAt: now,
|
|
2144
|
-
payload: { iteration: updated }
|
|
2145
|
-
};
|
|
2146
|
-
await this.events.publish(event);
|
|
2147
|
-
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
2148
|
-
const event: IterationCompletedEvent = {
|
|
2149
|
-
id: `evt_${crypto.randomUUID()}`,
|
|
2150
|
-
name: 'iteration.completed',
|
|
2151
|
-
aggregateId: updated.id,
|
|
2152
|
-
aggregateType: 'Iteration',
|
|
2153
|
-
occurredAt: now,
|
|
2154
|
-
payload: { iteration: updated }
|
|
2155
|
-
};
|
|
2156
|
-
await this.events.publish(event);
|
|
2157
|
-
}
|
|
2158
|
-
}
|
|
2159
|
-
return updated;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
/** Deletes an iteration. Its tasks are kept and moved back to the backlog (no iteration). */
|
|
2163
|
-
async deleteIteration(id: string): Promise<boolean> {
|
|
2164
|
-
const existing = await this.getIteration(id);
|
|
2165
|
-
if (!existing) return false;
|
|
2166
|
-
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
2167
|
-
await this.clearTaskReferences(existing.projectId, 'iterationId', id);
|
|
2168
|
-
const deleted = await this.store.deleteIteration(id);
|
|
2169
|
-
if (deleted) {
|
|
2170
|
-
await this.publishEvent<IterationDeletedEvent>({
|
|
2171
|
-
name: 'iteration.deleted',
|
|
2172
|
-
aggregateId: id,
|
|
2173
|
-
aggregateType: 'Iteration',
|
|
2174
|
-
payload: { iterationId: id, projectId: existing.projectId, name: existing.name }
|
|
2175
|
-
});
|
|
2176
|
-
}
|
|
2177
|
-
return deleted;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// --- Ladder of Abstraction & Critical Path Method ---
|
|
2181
|
-
async calculateCriticalPath(
|
|
2182
|
-
projectId: string,
|
|
2183
|
-
options: CPMOptions = {}
|
|
2184
|
-
): Promise<CriticalPathAnalysis> {
|
|
2185
|
-
await this.requireProjectAccess('project.read', projectId);
|
|
2186
|
-
const project = await this.store.getProject(projectId);
|
|
2187
|
-
const tasks = await this.store.getTasks(projectId);
|
|
2188
|
-
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
2189
|
-
const seenDepIds = new Set<string>();
|
|
2190
|
-
const dependencies: TaskDependency[] = [];
|
|
2191
|
-
for (const deps of allDepArrays) {
|
|
2192
|
-
for (const d of deps) {
|
|
2193
|
-
if (!seenDepIds.has(d.id)) {
|
|
2194
|
-
seenDepIds.add(d.id);
|
|
2195
|
-
dependencies.push(d);
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
2200
|
-
const projectStartDate = options.projectStartDate || project?.startDate;
|
|
2201
|
-
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
async getTimelineLadder(
|
|
2205
|
-
projectId: string,
|
|
2206
|
-
options: TimelineLadderOptions = {}
|
|
2207
|
-
): Promise<TimelineLadder> {
|
|
2208
|
-
await this.requireProjectAccess('project.read', projectId);
|
|
2209
|
-
const project = await this.store.getProject(projectId);
|
|
2210
|
-
if (!project) {
|
|
2211
|
-
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
let tasks = await this.store.getTasks(projectId);
|
|
2215
|
-
if (options.containerId) {
|
|
2216
|
-
tasks = tasks.filter((t) => t.containerId === options.containerId);
|
|
2217
|
-
}
|
|
2218
|
-
if (options.iterationId) {
|
|
2219
|
-
tasks = tasks.filter((t) => t.iterationId === options.iterationId);
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
const [containers, iterations, deliverables, attachments, activities] = await Promise.all([
|
|
2223
|
-
this.store.getContainers(projectId),
|
|
2224
|
-
this.store.getIterations(projectId),
|
|
2225
|
-
this.store.getDeliverables(projectId),
|
|
2226
|
-
this.store.getAttachments({ projectId }),
|
|
2227
|
-
this.store.getActivities({ projectId })
|
|
2228
|
-
]);
|
|
2229
|
-
|
|
2230
|
-
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
2231
|
-
const seenDepIds = new Set<string>();
|
|
2232
|
-
const dependencies: TaskDependency[] = [];
|
|
2233
|
-
for (const deps of allDepArrays) {
|
|
2234
|
-
for (const d of deps) {
|
|
2235
|
-
if (!seenDepIds.has(d.id)) {
|
|
2236
|
-
seenDepIds.add(d.id);
|
|
2237
|
-
dependencies.push(d);
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
2243
|
-
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
2244
|
-
|
|
2245
|
-
const schedule = project.schedule || this.config.defaultSchedule;
|
|
2246
|
-
|
|
2247
|
-
return buildTimelineLadder(
|
|
2248
|
-
{
|
|
2249
|
-
project,
|
|
2250
|
-
tasks,
|
|
2251
|
-
dependencies,
|
|
2252
|
-
containers,
|
|
2253
|
-
iterations,
|
|
2254
|
-
deliverables,
|
|
2255
|
-
attachments,
|
|
2256
|
-
timeEntries,
|
|
2257
|
-
activities,
|
|
2258
|
-
schedule
|
|
2259
|
-
},
|
|
2260
|
-
options
|
|
2261
|
-
);
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
|
|
2265
|
-
const task = await this.getTask(taskId);
|
|
2266
|
-
if (!task) return null;
|
|
2267
|
-
|
|
2268
|
-
const project = await this.store.getProject(task.projectId);
|
|
2269
|
-
if (!project) return null;
|
|
2270
|
-
|
|
2271
|
-
const ladder = await this.getTimelineLadder(task.projectId, { level: 'all' });
|
|
2272
|
-
const standard = ladder.standard?.tasks.find((t) => t.id === taskId);
|
|
2273
|
-
if (!standard) return null;
|
|
2274
|
-
|
|
2275
|
-
const concrete = ladder.concrete?.[taskId] || aggregateConcreteEvidenceForTask(task);
|
|
2276
|
-
const macroPhase = ladder.macro?.phases.find((p) => p.taskIds.includes(taskId));
|
|
2277
|
-
const metrics = await this.getTaskMetrics(taskId);
|
|
2278
|
-
|
|
2279
|
-
return {
|
|
2280
|
-
taskId,
|
|
2281
|
-
macroPhase,
|
|
2282
|
-
standard,
|
|
2283
|
-
concrete,
|
|
2284
|
-
metrics: metrics || undefined
|
|
2285
|
-
};
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
|
|
2289
|
-
const task = await this.getTask(taskId);
|
|
2290
|
-
if (!task) return null;
|
|
2291
|
-
|
|
2292
|
-
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
2293
|
-
this.store.getActivities({ taskId }),
|
|
2294
|
-
this.store.getTimeEntries(taskId),
|
|
2295
|
-
this.store.getProject(task.projectId),
|
|
2296
|
-
this.resolveProjectWorkflow(task.projectId)
|
|
2297
|
-
]);
|
|
2298
|
-
|
|
2299
|
-
const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
|
|
2300
|
-
|
|
2301
|
-
return calculateTaskMetrics(task, activities, timeEntries, {
|
|
2302
|
-
customStatusDefinitions,
|
|
2303
|
-
...options
|
|
2304
|
-
});
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
|
|
2308
|
-
const task = await this.getTask(taskId);
|
|
2309
|
-
if (!task) return null;
|
|
2310
|
-
|
|
2311
|
-
const [activities, project, workflow] = await Promise.all([
|
|
2312
|
-
this.store.getActivities({ taskId }),
|
|
2313
|
-
this.store.getProject(task.projectId),
|
|
2314
|
-
this.resolveProjectWorkflow(task.projectId)
|
|
2315
|
-
]);
|
|
2316
|
-
|
|
2317
|
-
const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
|
|
2318
|
-
|
|
2319
|
-
return reconstructTaskProgressHistory(task, activities, {
|
|
2320
|
-
customStatusDefinitions,
|
|
2321
|
-
...options
|
|
2322
|
-
});
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
async getWorkloadDistribution(
|
|
2326
|
-
projectId?: string,
|
|
2327
|
-
options: WorkloadDistributionOptions = {}
|
|
2328
|
-
): Promise<WorkloadDistribution> {
|
|
2329
|
-
if (projectId) await this.requireProjectAccess('project.read', projectId);
|
|
2330
|
-
const tasks = await this.getTasks(projectId);
|
|
2331
|
-
const teams = await this.getTeams();
|
|
2332
|
-
const users = await this.getUsers();
|
|
2333
|
-
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
2334
|
-
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
2335
|
-
|
|
2336
|
-
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
2337
|
-
const timeEntries: TimeEntry[] = timeEntriesNested.flat();
|
|
2338
|
-
|
|
2339
|
-
return calculateWorkloadDistribution(
|
|
2340
|
-
{
|
|
2341
|
-
tasks,
|
|
2342
|
-
timeEntries,
|
|
2343
|
-
teams,
|
|
2344
|
-
users,
|
|
2345
|
-
projectId
|
|
2346
|
-
},
|
|
2347
|
-
{
|
|
2348
|
-
...options,
|
|
2349
|
-
schedule
|
|
2350
|
-
}
|
|
2351
|
-
);
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
// --- Users ---
|
|
2355
|
-
|
|
2356
|
-
/**
|
|
2357
|
-
* The user directory from `config.users` plus `initialData.users`. Users are owned by your app;
|
|
2358
|
-
* the engine only reads them for names, capacity and schedules.
|
|
2359
|
-
*/
|
|
2360
|
-
async getUsers(): Promise<User[]> {
|
|
2361
|
-
const configured = typeof this.config.users === 'function' ? await this.config.users(this.actor) : this.config.users ?? [];
|
|
2362
|
-
const seeded = this.config.initialData?.users ?? [];
|
|
2363
|
-
const byId = new Map<string, User>();
|
|
2364
|
-
for (const user of [...seeded, ...configured]) byId.set(user.id, user);
|
|
2365
|
-
return Array.from(byId.values());
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
// --- Webhooks ---
|
|
2369
|
-
|
|
2370
|
-
/** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
|
|
2371
|
-
private async listAllWebhooks(): Promise<Webhook[]> {
|
|
2372
|
-
const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
|
|
2373
|
-
...w,
|
|
2374
|
-
id: `config_${i}`,
|
|
2375
|
-
createdAt: new Date(0).toISOString()
|
|
2376
|
-
}));
|
|
2377
|
-
return [...fromConfig, ...(await this.store.getWebhooks())];
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
|
|
2381
|
-
return { ...webhook, hasSecret: !!secret };
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
/** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
|
|
2385
|
-
async getWebhooks(): Promise<PublicWebhook[]> {
|
|
2386
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
2387
|
-
const webhooks = await this.store.getWebhooks();
|
|
2388
|
-
return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
async getWebhook(id: string): Promise<PublicWebhook | null> {
|
|
2392
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
2393
|
-
const webhook = await this.store.getWebhook(id);
|
|
2394
|
-
return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
/**
|
|
2398
|
-
* Registers a webhook. The signing secret is returned only here; generate one by omitting
|
|
2399
|
-
* `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
|
|
2400
|
-
*/
|
|
2401
|
-
async createWebhook(input: {
|
|
2402
|
-
name: string;
|
|
2403
|
-
url: string;
|
|
2404
|
-
events: Webhook['events'];
|
|
2405
|
-
secret?: string;
|
|
2406
|
-
active?: boolean;
|
|
2407
|
-
}): Promise<{ webhook: PublicWebhook; secret: string }> {
|
|
2408
|
-
await this.requireWorkspaceAccess('workspace.manage');
|
|
2409
|
-
this.validateWebhookUrl(input.url);
|
|
2410
|
-
const secret = input.secret ?? generateWebhookSecret();
|
|
2411
|
-
const created = await this.store.addWebhook({
|
|
2412
|
-
name: input.name,
|
|
2413
|
-
url: input.url,
|
|
2414
|
-
events: input.events,
|
|
2415
|
-
secret,
|
|
2416
|
-
active: input.active ?? true,
|
|
2417
|
-
tenantId: this.actor?.tenantId
|
|
2418
|
-
});
|
|
2419
|
-
this.webhooks.invalidate();
|
|
2420
|
-
return { webhook: this.toPublicWebhook(created), secret };
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
async updateWebhook(
|
|
2424
|
-
id: string,
|
|
2425
|
-
updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
|
|
2426
|
-
): Promise<PublicWebhook | null> {
|
|
2427
|
-
if (!(await this.getWebhook(id))) return null;
|
|
2428
|
-
if (updates.url) this.validateWebhookUrl(updates.url);
|
|
2429
|
-
const { name, url, events, active, secret } = updates;
|
|
2430
|
-
const updated = await this.store.updateWebhook(
|
|
2431
|
-
id,
|
|
2432
|
-
Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
|
|
2433
|
-
);
|
|
2434
|
-
this.webhooks.invalidate();
|
|
2435
|
-
return updated ? this.toPublicWebhook(updated) : null;
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
async deleteWebhook(id: string): Promise<boolean> {
|
|
2439
|
-
if (!(await this.getWebhook(id))) return false;
|
|
2440
|
-
const deleted = await this.store.deleteWebhook(id);
|
|
2441
|
-
this.webhooks.invalidate();
|
|
2442
|
-
return deleted;
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
private validateWebhookUrl(url: string): void {
|
|
2446
|
-
try {
|
|
2447
|
-
assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
|
|
2448
|
-
} catch (err) {
|
|
2449
|
-
throw new ValidationError((err as Error).message);
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
/** Finds the tenant an event belongs to, from the entity in its payload or its project. */
|
|
2454
|
-
private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
|
|
2455
|
-
const p = event.payload as Record<string, any>;
|
|
2456
|
-
const owner = p.project ?? p.workflow ?? p.team;
|
|
2457
|
-
if (owner) return owner.tenantId ?? undefined;
|
|
2458
|
-
// Deleted projects and workflows carry their tenant in the payload.
|
|
2459
|
-
if ('tenantId' in p) return p.tenantId ?? undefined;
|
|
2460
|
-
|
|
2461
|
-
const projectId: string | undefined =
|
|
2462
|
-
p.projectId ??
|
|
2463
|
-
p.task?.projectId ??
|
|
2464
|
-
p.deliverable?.projectId ??
|
|
2465
|
-
p.iteration?.projectId ??
|
|
2466
|
-
p.container?.projectId ??
|
|
2467
|
-
p.attachment?.projectId ??
|
|
2468
|
-
(await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
|
|
2469
|
-
if (!projectId) return undefined;
|
|
2470
|
-
return (await this.store.getProject(projectId))?.tenantId ?? undefined;
|
|
2471
|
-
}
|
|
2472
|
-
}
|