@critical-path/core 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +12 -0
- package/dist/domain/authorization.d.ts +23 -0
- package/dist/domain/authorization.d.ts.map +1 -0
- package/dist/domain/authorization.js +62 -0
- package/dist/domain/authorization.js.map +1 -0
- package/dist/domain/entities.d.ts +2 -2
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +6 -0
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +9 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/engine/authorization.test.d.ts +2 -0
- package/dist/engine/authorization.test.d.ts.map +1 -0
- package/dist/engine/authorization.test.js +150 -0
- package/dist/engine/authorization.test.js.map +1 -0
- package/dist/engine/index.d.ts +37 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +302 -48
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +40 -6
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +20 -9
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +45 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/authorization.ts +82 -0
- package/src/domain/entities.ts +2 -1
- package/src/domain/errors.ts +10 -0
- package/src/domain/index.ts +1 -0
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +295 -49
- package/src/schemas/index.ts +5 -1
- package/src/schemas/schemas.test.ts +3 -2
- package/src/store/sqlite.ts +20 -6
- package/src/types/index.ts +66 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
Activity,
|
|
2
3
|
Actor,
|
|
4
|
+
AuthorizationAction,
|
|
3
5
|
CriticalPathConfig,
|
|
4
6
|
Project,
|
|
5
7
|
Task,
|
|
@@ -82,7 +84,7 @@ import {
|
|
|
82
84
|
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
83
85
|
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
84
86
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
85
|
-
import { ValidationError, NotFoundError } from '../domain/errors.js';
|
|
87
|
+
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
86
88
|
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
87
89
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
88
90
|
import {
|
|
@@ -101,6 +103,8 @@ export class CriticalPathEngine {
|
|
|
101
103
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
102
104
|
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
103
105
|
public readonly actor?: Actor;
|
|
106
|
+
/** Set on internal views that keep the actor for attribution but skip authorization. */
|
|
107
|
+
private readonly authorizationBypassed?: boolean;
|
|
104
108
|
|
|
105
109
|
constructor(config: CriticalPathConfig = {}) {
|
|
106
110
|
this.config = config;
|
|
@@ -140,6 +144,112 @@ export class CriticalPathEngine {
|
|
|
140
144
|
return this.actor?.userId ?? fallback;
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
// --- Authorization (applies only to withActor views; the base engine is trusted) ---
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when calls must be checked: on a withActor view (not an internal elevated view) with a
|
|
151
|
+
* policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
|
|
152
|
+
*/
|
|
153
|
+
private get enforcing(): boolean {
|
|
154
|
+
return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
|
|
158
|
+
private elevated(): CriticalPathEngine {
|
|
159
|
+
if (!this.enforcing) return this;
|
|
160
|
+
const view = Object.create(this) as CriticalPathEngine;
|
|
161
|
+
Object.defineProperty(view, 'authorizationBypassed', { value: true });
|
|
162
|
+
return view;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
|
|
166
|
+
const tenant = this.actor?.tenantId;
|
|
167
|
+
return !tenant || entity?.tenantId === tenant;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async isAllowed(
|
|
171
|
+
action: AuthorizationAction,
|
|
172
|
+
project?: Project,
|
|
173
|
+
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
if (!this.enforcing) return true;
|
|
176
|
+
if (project && !this.inActorTenant(project)) return false;
|
|
177
|
+
const policy = this.config.authorize;
|
|
178
|
+
return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The project if the actor may read it, otherwise null (callers report "not found"). */
|
|
182
|
+
private async readableProject(projectId: string | undefined): Promise<Project | null> {
|
|
183
|
+
if (!projectId) return null;
|
|
184
|
+
const project = await this.store.getProject(projectId);
|
|
185
|
+
if (!project) return null;
|
|
186
|
+
return (await this.isAllowed('project.read', project)) ? project : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async canReadProject(projectId: string | undefined): Promise<boolean> {
|
|
190
|
+
if (!this.enforcing) return true;
|
|
191
|
+
return (await this.readableProject(projectId)) !== null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
|
|
196
|
+
* that belong to another tenant) are reported as not found so their existence is not revealed.
|
|
197
|
+
*/
|
|
198
|
+
private async requireProjectAccess(
|
|
199
|
+
action: AuthorizationAction,
|
|
200
|
+
projectId: string | undefined,
|
|
201
|
+
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
if (!this.enforcing) return;
|
|
204
|
+
const project = await this.readableProject(projectId);
|
|
205
|
+
if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
|
|
206
|
+
if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
|
|
207
|
+
throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
|
|
212
|
+
if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
|
|
216
|
+
private async filterReadable<T>(
|
|
217
|
+
items: T[],
|
|
218
|
+
projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
|
|
219
|
+
): Promise<T[]> {
|
|
220
|
+
if (!this.enforcing) return items;
|
|
221
|
+
const readable = new Map<string, boolean>();
|
|
222
|
+
const result: T[] = [];
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
const projectId = await projectIdOf(item);
|
|
225
|
+
if (!projectId) continue;
|
|
226
|
+
if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
|
|
227
|
+
if (readable.get(projectId)) result.push(item);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
|
|
233
|
+
return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
|
|
237
|
+
if (!commentId) return undefined;
|
|
238
|
+
const comment = await this.store.getComment(commentId);
|
|
239
|
+
return this.projectIdOfTask(comment?.taskId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
|
|
243
|
+
return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Strips fields only the engine may set (tenancy) from updates made through a view. */
|
|
247
|
+
private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
|
|
248
|
+
if (!this.actor || !('tenantId' in updates)) return updates;
|
|
249
|
+
const { tenantId: _ignored, ...rest } = updates;
|
|
250
|
+
return rest as T;
|
|
251
|
+
}
|
|
252
|
+
|
|
143
253
|
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
144
254
|
if (data.workflows) {
|
|
145
255
|
for (const wf of data.workflows) {
|
|
@@ -180,15 +290,18 @@ export class CriticalPathEngine {
|
|
|
180
290
|
|
|
181
291
|
// --- Workflows ---
|
|
182
292
|
async getWorkflows(): Promise<Workflow[]> {
|
|
183
|
-
|
|
293
|
+
const workflows = await this.store.getWorkflows();
|
|
294
|
+
return workflows.filter((w) => this.inActorTenant(w));
|
|
184
295
|
}
|
|
185
296
|
|
|
186
297
|
async getWorkflow(id: string): Promise<Workflow | null> {
|
|
187
|
-
|
|
298
|
+
const workflow = await this.store.getWorkflow(id);
|
|
299
|
+
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
188
300
|
}
|
|
189
301
|
|
|
190
302
|
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
191
|
-
|
|
303
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
304
|
+
const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
|
|
192
305
|
const now = new Date().toISOString();
|
|
193
306
|
|
|
194
307
|
const event: WorkflowCreatedEvent = {
|
|
@@ -211,8 +324,10 @@ export class CriticalPathEngine {
|
|
|
211
324
|
}
|
|
212
325
|
|
|
213
326
|
async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
|
|
214
|
-
const existing = await this.
|
|
327
|
+
const existing = await this.getWorkflow(id);
|
|
215
328
|
if (!existing) return null;
|
|
329
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
330
|
+
updates = this.withoutTenant(updates);
|
|
216
331
|
|
|
217
332
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
218
333
|
if (updated) {
|
|
@@ -238,8 +353,9 @@ export class CriticalPathEngine {
|
|
|
238
353
|
}
|
|
239
354
|
|
|
240
355
|
async deleteWorkflow(id: string): Promise<boolean> {
|
|
241
|
-
const existing = await this.
|
|
356
|
+
const existing = await this.getWorkflow(id);
|
|
242
357
|
if (!existing) return false;
|
|
358
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
243
359
|
|
|
244
360
|
const deleted = await this.store.deleteWorkflow(id);
|
|
245
361
|
if (deleted) {
|
|
@@ -272,7 +388,8 @@ export class CriticalPathEngine {
|
|
|
272
388
|
if (wf) return wf;
|
|
273
389
|
}
|
|
274
390
|
|
|
275
|
-
|
|
391
|
+
// Only fall back to workflows from the project's own tenant.
|
|
392
|
+
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
|
|
276
393
|
const defaultWf = workflows.find((w) => w.isDefault);
|
|
277
394
|
if (defaultWf) return defaultWf;
|
|
278
395
|
if (workflows.length > 0) return workflows[0];
|
|
@@ -281,21 +398,21 @@ export class CriticalPathEngine {
|
|
|
281
398
|
}
|
|
282
399
|
|
|
283
400
|
async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
|
|
284
|
-
const task = await this.
|
|
401
|
+
const task = await this.getTask(taskId);
|
|
285
402
|
if (!task) return [];
|
|
286
403
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
287
404
|
return getAllowedTransitions(workflow || undefined, task.status);
|
|
288
405
|
}
|
|
289
406
|
|
|
290
407
|
async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
|
|
291
|
-
const task = await this.
|
|
408
|
+
const task = await this.getTask(taskId);
|
|
292
409
|
if (!task) return [];
|
|
293
410
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
294
411
|
return getAllowedNextStatuses(workflow || undefined, task.status);
|
|
295
412
|
}
|
|
296
413
|
|
|
297
414
|
async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
|
|
298
|
-
const task = await this.
|
|
415
|
+
const task = await this.getTask(taskId);
|
|
299
416
|
if (!task) return [];
|
|
300
417
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
301
418
|
return getAllowedPreviousStatuses(workflow || undefined, task.status);
|
|
@@ -303,15 +420,24 @@ export class CriticalPathEngine {
|
|
|
303
420
|
|
|
304
421
|
// --- Projects ---
|
|
305
422
|
async getProjects(): Promise<Project[]> {
|
|
306
|
-
|
|
423
|
+
const projects = await this.store.getProjects();
|
|
424
|
+
return this.filterReadable(projects, (p) => p.id);
|
|
307
425
|
}
|
|
308
426
|
|
|
309
427
|
async getProject(id: string): Promise<Project | null> {
|
|
310
|
-
return this.store.getProject(id);
|
|
428
|
+
if (!this.enforcing) return this.store.getProject(id);
|
|
429
|
+
return this.readableProject(id);
|
|
311
430
|
}
|
|
312
431
|
|
|
313
432
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
314
|
-
|
|
433
|
+
await this.requireWorkspaceAccess('project.create');
|
|
434
|
+
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
435
|
+
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
436
|
+
// The creator administers the project they create.
|
|
437
|
+
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
|
|
438
|
+
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
439
|
+
}
|
|
440
|
+
const created = await this.store.createProject(input);
|
|
315
441
|
const now = new Date().toISOString();
|
|
316
442
|
|
|
317
443
|
const event: ProjectCreatedEvent = {
|
|
@@ -335,8 +461,11 @@ export class CriticalPathEngine {
|
|
|
335
461
|
}
|
|
336
462
|
|
|
337
463
|
async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
|
|
338
|
-
const existing = await this.
|
|
464
|
+
const existing = await this.getProject(id);
|
|
339
465
|
if (!existing) return null;
|
|
466
|
+
await this.requireProjectAccess('project.update', id);
|
|
467
|
+
if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
|
|
468
|
+
updates = this.withoutTenant(updates);
|
|
340
469
|
|
|
341
470
|
const updated = await this.store.updateProject(id, updates);
|
|
342
471
|
if (updated) {
|
|
@@ -367,13 +496,16 @@ export class CriticalPathEngine {
|
|
|
367
496
|
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
368
497
|
*/
|
|
369
498
|
async deleteProject(id: string): Promise<boolean> {
|
|
370
|
-
const existing = await this.
|
|
499
|
+
const existing = await this.getProject(id);
|
|
371
500
|
if (!existing) return false;
|
|
501
|
+
await this.requireProjectAccess('project.delete', id);
|
|
502
|
+
// Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
|
|
503
|
+
const cascade = this.elevated();
|
|
372
504
|
|
|
373
505
|
const tasks = await this.store.getTasks(id);
|
|
374
506
|
const deletedTaskIds: string[] = [];
|
|
375
507
|
for (const task of tasks) {
|
|
376
|
-
if (await
|
|
508
|
+
if (await cascade.deleteTask(task.id)) {
|
|
377
509
|
deletedTaskIds.push(task.id);
|
|
378
510
|
}
|
|
379
511
|
}
|
|
@@ -404,14 +536,19 @@ export class CriticalPathEngine {
|
|
|
404
536
|
|
|
405
537
|
// --- Tasks ---
|
|
406
538
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
407
|
-
|
|
539
|
+
if (projectId && !(await this.canReadProject(projectId))) return [];
|
|
540
|
+
const tasks = await this.store.getTasks(projectId);
|
|
541
|
+
return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
|
|
408
542
|
}
|
|
409
543
|
|
|
410
544
|
async getTask(id: string): Promise<Task | null> {
|
|
411
|
-
|
|
545
|
+
const task = await this.store.getTask(id);
|
|
546
|
+
if (!task) return null;
|
|
547
|
+
return (await this.canReadProject(task.projectId)) ? task : null;
|
|
412
548
|
}
|
|
413
549
|
|
|
414
550
|
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
551
|
+
await this.requireProjectAccess('task.create', taskInput.projectId);
|
|
415
552
|
if (this.actor && !taskInput.reporterId) {
|
|
416
553
|
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
417
554
|
}
|
|
@@ -519,8 +656,9 @@ export class CriticalPathEngine {
|
|
|
519
656
|
actor?: { userId: string; username?: string; actorType?: string };
|
|
520
657
|
}
|
|
521
658
|
): Promise<Task | null> {
|
|
522
|
-
const existing = await this.
|
|
659
|
+
const existing = await this.getTask(id);
|
|
523
660
|
if (!existing) return null;
|
|
661
|
+
await this.requireProjectAccess('task.update', existing.projectId);
|
|
524
662
|
|
|
525
663
|
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
526
664
|
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
@@ -916,8 +1054,9 @@ export class CriticalPathEngine {
|
|
|
916
1054
|
}
|
|
917
1055
|
|
|
918
1056
|
async deleteTask(id: string): Promise<boolean> {
|
|
919
|
-
const existing = await this.
|
|
1057
|
+
const existing = await this.getTask(id);
|
|
920
1058
|
if (!existing) return false;
|
|
1059
|
+
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
921
1060
|
|
|
922
1061
|
await this.plugins.runBeforeTaskDelete(id);
|
|
923
1062
|
const deleted = await this.store.deleteTask(id);
|
|
@@ -954,6 +1093,12 @@ export class CriticalPathEngine {
|
|
|
954
1093
|
|
|
955
1094
|
// --- Task Dependencies & Graph ---
|
|
956
1095
|
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
1096
|
+
if (this.enforcing) {
|
|
1097
|
+
const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
|
|
1098
|
+
if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
|
|
1099
|
+
if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
|
|
1100
|
+
await this.requireProjectAccess('task.update', task.projectId);
|
|
1101
|
+
}
|
|
957
1102
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
958
1103
|
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
959
1104
|
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
@@ -1009,6 +1154,9 @@ export class CriticalPathEngine {
|
|
|
1009
1154
|
}
|
|
1010
1155
|
|
|
1011
1156
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
1157
|
+
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
1158
|
+
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
1159
|
+
}
|
|
1012
1160
|
const dependencies = await this.store.getDependencies(taskId);
|
|
1013
1161
|
const upstreamTaskIds = new Set<string>();
|
|
1014
1162
|
const downstreamTaskIds = new Set<string>();
|
|
@@ -1022,11 +1170,11 @@ export class CriticalPathEngine {
|
|
|
1022
1170
|
}
|
|
1023
1171
|
|
|
1024
1172
|
const upstreamTasks = (
|
|
1025
|
-
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.
|
|
1173
|
+
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
|
|
1026
1174
|
).filter((t): t is Task => t !== null);
|
|
1027
1175
|
|
|
1028
1176
|
const downstreamTasks = (
|
|
1029
|
-
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
1177
|
+
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
|
|
1030
1178
|
).filter((t): t is Task => t !== null);
|
|
1031
1179
|
|
|
1032
1180
|
return {
|
|
@@ -1064,7 +1212,11 @@ export class CriticalPathEngine {
|
|
|
1064
1212
|
throw new ValidationError('Logged hours must be a positive number.');
|
|
1065
1213
|
}
|
|
1066
1214
|
|
|
1067
|
-
const task = await this.
|
|
1215
|
+
const task = await this.getTask(entry.taskId);
|
|
1216
|
+
if (this.enforcing) {
|
|
1217
|
+
if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
|
|
1218
|
+
await this.requireProjectAccess('time.log', task.projectId);
|
|
1219
|
+
}
|
|
1068
1220
|
if (task) {
|
|
1069
1221
|
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
1070
1222
|
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
@@ -1100,16 +1252,34 @@ export class CriticalPathEngine {
|
|
|
1100
1252
|
|
|
1101
1253
|
// --- Comments ---
|
|
1102
1254
|
async getComments(taskId: string): Promise<Comment[]> {
|
|
1255
|
+
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1103
1256
|
return this.store.getComments(taskId);
|
|
1104
1257
|
}
|
|
1105
1258
|
|
|
1106
1259
|
async getComment(id: string): Promise<Comment | null> {
|
|
1107
|
-
|
|
1260
|
+
const comment = await this.store.getComment(id);
|
|
1261
|
+
if (!comment) return null;
|
|
1262
|
+
return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
|
|
1266
|
+
const activities = await this.store.getActivities(filter);
|
|
1267
|
+
return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
|
|
1271
|
+
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1272
|
+
return this.store.getTimeEntries(taskId);
|
|
1108
1273
|
}
|
|
1109
1274
|
|
|
1110
1275
|
async addComment(
|
|
1111
1276
|
input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
|
|
1112
1277
|
): Promise<Comment> {
|
|
1278
|
+
if (this.enforcing) {
|
|
1279
|
+
const task = await this.getTask(input.taskId);
|
|
1280
|
+
if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
|
|
1281
|
+
await this.requireProjectAccess('comment.create', task.projectId);
|
|
1282
|
+
}
|
|
1113
1283
|
const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1114
1284
|
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
1115
1285
|
: { ...input, authorId: input.authorId ?? 'system' };
|
|
@@ -1132,8 +1302,12 @@ export class CriticalPathEngine {
|
|
|
1132
1302
|
}
|
|
1133
1303
|
|
|
1134
1304
|
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
1135
|
-
const existing = await this.
|
|
1305
|
+
const existing = await this.getComment(id);
|
|
1136
1306
|
if (!existing) return null;
|
|
1307
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1308
|
+
type: 'comment',
|
|
1309
|
+
ownerId: existing.authorId
|
|
1310
|
+
});
|
|
1137
1311
|
|
|
1138
1312
|
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
1139
1313
|
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
@@ -1157,8 +1331,12 @@ export class CriticalPathEngine {
|
|
|
1157
1331
|
}
|
|
1158
1332
|
|
|
1159
1333
|
async deleteComment(id: string): Promise<boolean> {
|
|
1160
|
-
const existing = await this.
|
|
1334
|
+
const existing = await this.getComment(id);
|
|
1161
1335
|
if (!existing) return false;
|
|
1336
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1337
|
+
type: 'comment',
|
|
1338
|
+
ownerId: existing.authorId
|
|
1339
|
+
});
|
|
1162
1340
|
|
|
1163
1341
|
const deleted = await this.store.deleteComment(id);
|
|
1164
1342
|
if (deleted) {
|
|
@@ -1180,8 +1358,9 @@ export class CriticalPathEngine {
|
|
|
1180
1358
|
|
|
1181
1359
|
async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1182
1360
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1183
|
-
const existing = await this.
|
|
1361
|
+
const existing = await this.getComment(commentId);
|
|
1184
1362
|
if (!existing) return null;
|
|
1363
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1185
1364
|
|
|
1186
1365
|
let updated: Comment | null;
|
|
1187
1366
|
if (this.store.addReaction) {
|
|
@@ -1226,8 +1405,9 @@ export class CriticalPathEngine {
|
|
|
1226
1405
|
|
|
1227
1406
|
async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1228
1407
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1229
|
-
const existing = await this.
|
|
1408
|
+
const existing = await this.getComment(commentId);
|
|
1230
1409
|
if (!existing) return null;
|
|
1410
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1231
1411
|
|
|
1232
1412
|
let updated: Comment | null;
|
|
1233
1413
|
if (this.store.removeReaction) {
|
|
@@ -1258,7 +1438,9 @@ export class CriticalPathEngine {
|
|
|
1258
1438
|
|
|
1259
1439
|
// --- Attachments ---
|
|
1260
1440
|
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
|
|
1261
|
-
const attachments = await this.store.getAttachments(filter)
|
|
1441
|
+
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
|
|
1442
|
+
this.projectIdOfAttachment(a)
|
|
1443
|
+
);
|
|
1262
1444
|
if (filter?.artifactType) {
|
|
1263
1445
|
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1264
1446
|
}
|
|
@@ -1266,12 +1448,22 @@ export class CriticalPathEngine {
|
|
|
1266
1448
|
}
|
|
1267
1449
|
|
|
1268
1450
|
async getAttachment(id: string): Promise<Attachment | null> {
|
|
1269
|
-
|
|
1451
|
+
const attachment = await this.store.getAttachment(id);
|
|
1452
|
+
if (!attachment) return null;
|
|
1453
|
+
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
|
|
1457
|
+
if (!this.enforcing) return;
|
|
1458
|
+
const projectId = await this.projectIdOfAttachment(link);
|
|
1459
|
+
if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1460
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1270
1461
|
}
|
|
1271
1462
|
|
|
1272
1463
|
async createAttachment(
|
|
1273
1464
|
input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
|
|
1274
1465
|
): Promise<Attachment> {
|
|
1466
|
+
await this.requireAttachmentCreate(input);
|
|
1275
1467
|
const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1276
1468
|
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1277
1469
|
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
@@ -1294,8 +1486,12 @@ export class CriticalPathEngine {
|
|
|
1294
1486
|
}
|
|
1295
1487
|
|
|
1296
1488
|
async deleteAttachment(id: string): Promise<boolean> {
|
|
1297
|
-
const existing = await this.
|
|
1489
|
+
const existing = await this.getAttachment(id);
|
|
1298
1490
|
if (!existing) return false;
|
|
1491
|
+
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1492
|
+
type: 'attachment',
|
|
1493
|
+
ownerId: existing.uploaderId
|
|
1494
|
+
});
|
|
1299
1495
|
|
|
1300
1496
|
if (existing.storageKey && this.fileStorage) {
|
|
1301
1497
|
try {
|
|
@@ -1337,6 +1533,8 @@ export class CriticalPathEngine {
|
|
|
1337
1533
|
if (!this.fileStorage) {
|
|
1338
1534
|
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1339
1535
|
}
|
|
1536
|
+
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1537
|
+
await this.requireAttachmentCreate(input);
|
|
1340
1538
|
|
|
1341
1539
|
const uploadResult = await this.fileStorage.upload({
|
|
1342
1540
|
filename: input.filename,
|
|
@@ -1394,24 +1592,41 @@ export class CriticalPathEngine {
|
|
|
1394
1592
|
throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
|
|
1395
1593
|
}
|
|
1396
1594
|
|
|
1397
|
-
async getPresignedAttachmentUploadUrl(
|
|
1595
|
+
async getPresignedAttachmentUploadUrl(
|
|
1596
|
+
options: PresignedUrlOptions & { projectId?: string }
|
|
1597
|
+
): Promise<PresignedUploadResult> {
|
|
1398
1598
|
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1399
1599
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1400
1600
|
}
|
|
1401
|
-
|
|
1601
|
+
const { projectId, ...presign } = options;
|
|
1602
|
+
if (this.enforcing) {
|
|
1603
|
+
// Scoped callers may only write under their project's prefix.
|
|
1604
|
+
if (!projectId) throw new ValidationError('projectId is required for presigned uploads.');
|
|
1605
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1606
|
+
const prefix = `projects/${projectId}/`;
|
|
1607
|
+
const key = presign.storageKey.replace(/^\/+/, '');
|
|
1608
|
+
if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
1609
|
+
throw new ValidationError('storageKey must not contain "." or ".." segments.');
|
|
1610
|
+
}
|
|
1611
|
+
presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
|
|
1612
|
+
}
|
|
1613
|
+
return this.fileStorage.getPresignedUploadUrl(presign);
|
|
1402
1614
|
}
|
|
1403
1615
|
|
|
1404
1616
|
// --- Teams ---
|
|
1405
1617
|
async getTeams(): Promise<Team[]> {
|
|
1406
|
-
|
|
1618
|
+
const teams = await this.store.getTeams();
|
|
1619
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1407
1620
|
}
|
|
1408
1621
|
|
|
1409
1622
|
async getTeam(id: string): Promise<Team | null> {
|
|
1410
|
-
|
|
1623
|
+
const team = await this.store.getTeam(id);
|
|
1624
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1411
1625
|
}
|
|
1412
1626
|
|
|
1413
1627
|
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
1414
|
-
|
|
1628
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1629
|
+
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1415
1630
|
const now = new Date().toISOString();
|
|
1416
1631
|
|
|
1417
1632
|
const event: TeamCreatedEvent = {
|
|
@@ -1429,23 +1644,31 @@ export class CriticalPathEngine {
|
|
|
1429
1644
|
}
|
|
1430
1645
|
|
|
1431
1646
|
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
1432
|
-
|
|
1647
|
+
if (!(await this.getTeam(id))) return null;
|
|
1648
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1649
|
+
return this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1433
1650
|
}
|
|
1434
1651
|
|
|
1435
1652
|
async deleteTeam(id: string): Promise<boolean> {
|
|
1653
|
+
if (!(await this.getTeam(id))) return false;
|
|
1654
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1436
1655
|
return this.store.deleteTeam(id);
|
|
1437
1656
|
}
|
|
1438
1657
|
|
|
1439
1658
|
// --- Containers ---
|
|
1440
1659
|
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
1660
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1441
1661
|
return this.store.getContainers(projectId);
|
|
1442
1662
|
}
|
|
1443
1663
|
|
|
1444
1664
|
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
1445
|
-
|
|
1665
|
+
const container = await this.store.getContainer(id);
|
|
1666
|
+
if (!container) return null;
|
|
1667
|
+
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1446
1668
|
}
|
|
1447
1669
|
|
|
1448
1670
|
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
1671
|
+
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1449
1672
|
const created = await this.store.createContainer(container);
|
|
1450
1673
|
const now = new Date().toISOString();
|
|
1451
1674
|
|
|
@@ -1464,23 +1687,33 @@ export class CriticalPathEngine {
|
|
|
1464
1687
|
}
|
|
1465
1688
|
|
|
1466
1689
|
async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
|
|
1690
|
+
const existing = await this.getContainer(id);
|
|
1691
|
+
if (!existing) return null;
|
|
1692
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1467
1693
|
return this.store.updateContainer(id, updates);
|
|
1468
1694
|
}
|
|
1469
1695
|
|
|
1470
1696
|
async deleteContainer(id: string): Promise<boolean> {
|
|
1697
|
+
const existing = await this.getContainer(id);
|
|
1698
|
+
if (!existing) return false;
|
|
1699
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1471
1700
|
return this.store.deleteContainer(id);
|
|
1472
1701
|
}
|
|
1473
1702
|
|
|
1474
1703
|
// --- Deliverables ---
|
|
1475
1704
|
async getDeliverables(projectId: string): Promise<Deliverable[]> {
|
|
1705
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1476
1706
|
return this.store.getDeliverables(projectId);
|
|
1477
1707
|
}
|
|
1478
1708
|
|
|
1479
1709
|
async getDeliverable(id: string): Promise<Deliverable | null> {
|
|
1480
|
-
|
|
1710
|
+
const deliverable = await this.store.getDeliverable(id);
|
|
1711
|
+
if (!deliverable) return null;
|
|
1712
|
+
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1481
1713
|
}
|
|
1482
1714
|
|
|
1483
1715
|
async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
|
|
1716
|
+
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1484
1717
|
const project = await this.store.getProject(input.projectId);
|
|
1485
1718
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1486
1719
|
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
@@ -1515,8 +1748,9 @@ export class CriticalPathEngine {
|
|
|
1515
1748
|
}
|
|
1516
1749
|
|
|
1517
1750
|
async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
|
|
1518
|
-
const existing = await this.
|
|
1751
|
+
const existing = await this.getDeliverable(id);
|
|
1519
1752
|
if (!existing) return null;
|
|
1753
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1520
1754
|
|
|
1521
1755
|
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1522
1756
|
updates.deliveredAt = new Date().toISOString();
|
|
@@ -1569,8 +1803,9 @@ export class CriticalPathEngine {
|
|
|
1569
1803
|
}
|
|
1570
1804
|
|
|
1571
1805
|
async deleteDeliverable(id: string): Promise<boolean> {
|
|
1572
|
-
const existing = await this.
|
|
1806
|
+
const existing = await this.getDeliverable(id);
|
|
1573
1807
|
if (!existing) return false;
|
|
1808
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1574
1809
|
|
|
1575
1810
|
const deleted = await this.store.deleteDeliverable(id);
|
|
1576
1811
|
if (deleted) {
|
|
@@ -1597,7 +1832,7 @@ export class CriticalPathEngine {
|
|
|
1597
1832
|
}
|
|
1598
1833
|
|
|
1599
1834
|
async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
|
|
1600
|
-
const deliverable = await this.
|
|
1835
|
+
const deliverable = await this.getDeliverable(deliverableId);
|
|
1601
1836
|
if (!deliverable) return null;
|
|
1602
1837
|
|
|
1603
1838
|
const project = await this.store.getProject(deliverable.projectId);
|
|
@@ -1645,14 +1880,18 @@ export class CriticalPathEngine {
|
|
|
1645
1880
|
|
|
1646
1881
|
// --- Iterations ---
|
|
1647
1882
|
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
1883
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1648
1884
|
return this.store.getIterations(projectId);
|
|
1649
1885
|
}
|
|
1650
1886
|
|
|
1651
1887
|
async getIteration(id: string): Promise<Iteration | null> {
|
|
1652
|
-
|
|
1888
|
+
const iteration = await this.store.getIteration(id);
|
|
1889
|
+
if (!iteration) return null;
|
|
1890
|
+
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
1653
1891
|
}
|
|
1654
1892
|
|
|
1655
1893
|
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
1894
|
+
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
1656
1895
|
const created = await this.store.createIteration(iteration);
|
|
1657
1896
|
if (created.status === 'active') {
|
|
1658
1897
|
const now = new Date().toISOString();
|
|
@@ -1671,8 +1910,9 @@ export class CriticalPathEngine {
|
|
|
1671
1910
|
}
|
|
1672
1911
|
|
|
1673
1912
|
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
1674
|
-
const existing = await this.
|
|
1913
|
+
const existing = await this.getIteration(id);
|
|
1675
1914
|
if (!existing) return null;
|
|
1915
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1676
1916
|
|
|
1677
1917
|
const updated = await this.store.updateIteration(id, updates);
|
|
1678
1918
|
if (updated) {
|
|
@@ -1705,6 +1945,9 @@ export class CriticalPathEngine {
|
|
|
1705
1945
|
}
|
|
1706
1946
|
|
|
1707
1947
|
async deleteIteration(id: string): Promise<boolean> {
|
|
1948
|
+
const existing = await this.getIteration(id);
|
|
1949
|
+
if (!existing) return false;
|
|
1950
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1708
1951
|
return this.store.deleteIteration(id);
|
|
1709
1952
|
}
|
|
1710
1953
|
|
|
@@ -1713,6 +1956,7 @@ export class CriticalPathEngine {
|
|
|
1713
1956
|
projectId: string,
|
|
1714
1957
|
options: CPMOptions = {}
|
|
1715
1958
|
): Promise<CriticalPathAnalysis> {
|
|
1959
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1716
1960
|
const project = await this.store.getProject(projectId);
|
|
1717
1961
|
const tasks = await this.store.getTasks(projectId);
|
|
1718
1962
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1735,6 +1979,7 @@ export class CriticalPathEngine {
|
|
|
1735
1979
|
projectId: string,
|
|
1736
1980
|
options: TimelineLadderOptions = {}
|
|
1737
1981
|
): Promise<TimelineLadder> {
|
|
1982
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1738
1983
|
const project = await this.store.getProject(projectId);
|
|
1739
1984
|
if (!project) {
|
|
1740
1985
|
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
@@ -1791,7 +2036,7 @@ export class CriticalPathEngine {
|
|
|
1791
2036
|
}
|
|
1792
2037
|
|
|
1793
2038
|
async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
|
|
1794
|
-
const task = await this.
|
|
2039
|
+
const task = await this.getTask(taskId);
|
|
1795
2040
|
if (!task) return null;
|
|
1796
2041
|
|
|
1797
2042
|
const project = await this.store.getProject(task.projectId);
|
|
@@ -1815,7 +2060,7 @@ export class CriticalPathEngine {
|
|
|
1815
2060
|
}
|
|
1816
2061
|
|
|
1817
2062
|
async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
|
|
1818
|
-
const task = await this.
|
|
2063
|
+
const task = await this.getTask(taskId);
|
|
1819
2064
|
if (!task) return null;
|
|
1820
2065
|
|
|
1821
2066
|
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
@@ -1834,7 +2079,7 @@ export class CriticalPathEngine {
|
|
|
1834
2079
|
}
|
|
1835
2080
|
|
|
1836
2081
|
async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
|
|
1837
|
-
const task = await this.
|
|
2082
|
+
const task = await this.getTask(taskId);
|
|
1838
2083
|
if (!task) return null;
|
|
1839
2084
|
|
|
1840
2085
|
const [activities, project, workflow] = await Promise.all([
|
|
@@ -1855,8 +2100,9 @@ export class CriticalPathEngine {
|
|
|
1855
2100
|
projectId?: string,
|
|
1856
2101
|
options: WorkloadDistributionOptions = {}
|
|
1857
2102
|
): Promise<WorkloadDistribution> {
|
|
1858
|
-
|
|
1859
|
-
const
|
|
2103
|
+
if (projectId) await this.requireProjectAccess('project.read', projectId);
|
|
2104
|
+
const tasks = await this.getTasks(projectId);
|
|
2105
|
+
const teams = await this.getTeams();
|
|
1860
2106
|
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1861
2107
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1862
2108
|
|