@critical-path/core 0.24.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 +53 -0
- package/README.md +15 -3
- 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/actor.test.js +1 -0
- package/dist/engine/actor.test.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 +49 -19
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +321 -87
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +1 -5
- package/dist/index.test.js.map +1 -1
- package/dist/schemas/index.d.ts +127 -130
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +41 -50
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +10 -11
- 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/actor.test.ts +2 -1
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +318 -100
- package/src/index.test.ts +1 -5
- package/src/schemas/index.ts +67 -51
- package/src/schemas/schemas.test.ts +19 -17
- 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
|
}
|
|
@@ -511,12 +648,7 @@ export class CriticalPathEngine {
|
|
|
511
648
|
|
|
512
649
|
async updateTask(
|
|
513
650
|
id: string,
|
|
514
|
-
updates: Partial<Task
|
|
515
|
-
actorId?: string;
|
|
516
|
-
actorName?: string;
|
|
517
|
-
actorType?: string;
|
|
518
|
-
actor?: { userId: string; username?: string; actorType?: string };
|
|
519
|
-
},
|
|
651
|
+
updates: Partial<Task>,
|
|
520
652
|
options?: {
|
|
521
653
|
actorId?: string;
|
|
522
654
|
actorName?: string;
|
|
@@ -524,45 +656,19 @@ export class CriticalPathEngine {
|
|
|
524
656
|
actor?: { userId: string; username?: string; actorType?: string };
|
|
525
657
|
}
|
|
526
658
|
): Promise<Task | null> {
|
|
527
|
-
const existing = await this.
|
|
659
|
+
const existing = await this.getTask(id);
|
|
528
660
|
if (!existing) return null;
|
|
661
|
+
await this.requireProjectAccess('task.update', existing.projectId);
|
|
529
662
|
|
|
530
|
-
//
|
|
531
|
-
// Explicit options from trusted server-side code still take precedence.
|
|
663
|
+
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
532
664
|
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
const actorId =
|
|
536
|
-
options?.actorId ||
|
|
537
|
-
claimed?.actorId ||
|
|
538
|
-
options?.actor?.userId ||
|
|
539
|
-
claimed?.actor?.userId ||
|
|
540
|
-
'system';
|
|
665
|
+
const actorId = options?.actorId || options?.actor?.userId || 'system';
|
|
541
666
|
const actorName =
|
|
542
|
-
options?.actorName ||
|
|
543
|
-
claimed?.actorName ||
|
|
544
|
-
options?.actor?.username ||
|
|
545
|
-
claimed?.actor?.username ||
|
|
546
|
-
(actorId === 'system' ? 'System' : undefined);
|
|
667
|
+
options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
|
|
547
668
|
const actorType =
|
|
548
|
-
options?.actorType ||
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
claimed?.actor?.actorType ||
|
|
552
|
-
(actorId === 'system' ? 'system' : 'user');
|
|
553
|
-
const actorObj = options?.actor || claimed?.actor || {
|
|
554
|
-
userId: actorId,
|
|
555
|
-
username: actorName,
|
|
556
|
-
actorType
|
|
557
|
-
};
|
|
558
|
-
|
|
559
|
-
const {
|
|
560
|
-
actorId: _aId,
|
|
561
|
-
actorName: _aName,
|
|
562
|
-
actorType: _aType,
|
|
563
|
-
actor: _aObj,
|
|
564
|
-
...taskUpdates
|
|
565
|
-
} = updates;
|
|
669
|
+
options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
|
|
670
|
+
const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
|
|
671
|
+
const taskUpdates = updates;
|
|
566
672
|
|
|
567
673
|
const project = await this.store.getProject(existing.projectId);
|
|
568
674
|
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
@@ -948,8 +1054,9 @@ export class CriticalPathEngine {
|
|
|
948
1054
|
}
|
|
949
1055
|
|
|
950
1056
|
async deleteTask(id: string): Promise<boolean> {
|
|
951
|
-
const existing = await this.
|
|
1057
|
+
const existing = await this.getTask(id);
|
|
952
1058
|
if (!existing) return false;
|
|
1059
|
+
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
953
1060
|
|
|
954
1061
|
await this.plugins.runBeforeTaskDelete(id);
|
|
955
1062
|
const deleted = await this.store.deleteTask(id);
|
|
@@ -986,6 +1093,12 @@ export class CriticalPathEngine {
|
|
|
986
1093
|
|
|
987
1094
|
// --- Task Dependencies & Graph ---
|
|
988
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
|
+
}
|
|
989
1102
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
990
1103
|
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
991
1104
|
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
@@ -1041,6 +1154,9 @@ export class CriticalPathEngine {
|
|
|
1041
1154
|
}
|
|
1042
1155
|
|
|
1043
1156
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
1157
|
+
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
1158
|
+
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
1159
|
+
}
|
|
1044
1160
|
const dependencies = await this.store.getDependencies(taskId);
|
|
1045
1161
|
const upstreamTaskIds = new Set<string>();
|
|
1046
1162
|
const downstreamTaskIds = new Set<string>();
|
|
@@ -1054,11 +1170,11 @@ export class CriticalPathEngine {
|
|
|
1054
1170
|
}
|
|
1055
1171
|
|
|
1056
1172
|
const upstreamTasks = (
|
|
1057
|
-
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.
|
|
1173
|
+
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
|
|
1058
1174
|
).filter((t): t is Task => t !== null);
|
|
1059
1175
|
|
|
1060
1176
|
const downstreamTasks = (
|
|
1061
|
-
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
1177
|
+
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
|
|
1062
1178
|
).filter((t): t is Task => t !== null);
|
|
1063
1179
|
|
|
1064
1180
|
return {
|
|
@@ -1096,7 +1212,11 @@ export class CriticalPathEngine {
|
|
|
1096
1212
|
throw new ValidationError('Logged hours must be a positive number.');
|
|
1097
1213
|
}
|
|
1098
1214
|
|
|
1099
|
-
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
|
+
}
|
|
1100
1220
|
if (task) {
|
|
1101
1221
|
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
1102
1222
|
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
@@ -1132,17 +1252,37 @@ export class CriticalPathEngine {
|
|
|
1132
1252
|
|
|
1133
1253
|
// --- Comments ---
|
|
1134
1254
|
async getComments(taskId: string): Promise<Comment[]> {
|
|
1255
|
+
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1135
1256
|
return this.store.getComments(taskId);
|
|
1136
1257
|
}
|
|
1137
1258
|
|
|
1138
1259
|
async getComment(id: string): Promise<Comment | null> {
|
|
1139
|
-
|
|
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;
|
|
1140
1263
|
}
|
|
1141
1264
|
|
|
1142
|
-
async
|
|
1143
|
-
|
|
1144
|
-
|
|
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);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async addComment(
|
|
1276
|
+
input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
|
|
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);
|
|
1145
1282
|
}
|
|
1283
|
+
const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1284
|
+
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
1285
|
+
: { ...input, authorId: input.authorId ?? 'system' };
|
|
1146
1286
|
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
1147
1287
|
const created = await this.store.addComment({ ...comment, mentions });
|
|
1148
1288
|
const now = new Date().toISOString();
|
|
@@ -1162,8 +1302,12 @@ export class CriticalPathEngine {
|
|
|
1162
1302
|
}
|
|
1163
1303
|
|
|
1164
1304
|
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
1165
|
-
const existing = await this.
|
|
1305
|
+
const existing = await this.getComment(id);
|
|
1166
1306
|
if (!existing) return null;
|
|
1307
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1308
|
+
type: 'comment',
|
|
1309
|
+
ownerId: existing.authorId
|
|
1310
|
+
});
|
|
1167
1311
|
|
|
1168
1312
|
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
1169
1313
|
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
@@ -1187,8 +1331,12 @@ export class CriticalPathEngine {
|
|
|
1187
1331
|
}
|
|
1188
1332
|
|
|
1189
1333
|
async deleteComment(id: string): Promise<boolean> {
|
|
1190
|
-
const existing = await this.
|
|
1334
|
+
const existing = await this.getComment(id);
|
|
1191
1335
|
if (!existing) return false;
|
|
1336
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1337
|
+
type: 'comment',
|
|
1338
|
+
ownerId: existing.authorId
|
|
1339
|
+
});
|
|
1192
1340
|
|
|
1193
1341
|
const deleted = await this.store.deleteComment(id);
|
|
1194
1342
|
if (deleted) {
|
|
@@ -1208,10 +1356,11 @@ export class CriticalPathEngine {
|
|
|
1208
1356
|
return deleted;
|
|
1209
1357
|
}
|
|
1210
1358
|
|
|
1211
|
-
async addCommentReaction(commentId: string,
|
|
1212
|
-
|
|
1213
|
-
const existing = await this.
|
|
1359
|
+
async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1360
|
+
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1361
|
+
const existing = await this.getComment(commentId);
|
|
1214
1362
|
if (!existing) return null;
|
|
1363
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1215
1364
|
|
|
1216
1365
|
let updated: Comment | null;
|
|
1217
1366
|
if (this.store.addReaction) {
|
|
@@ -1254,10 +1403,11 @@ export class CriticalPathEngine {
|
|
|
1254
1403
|
return updated;
|
|
1255
1404
|
}
|
|
1256
1405
|
|
|
1257
|
-
async removeCommentReaction(commentId: string,
|
|
1258
|
-
|
|
1259
|
-
const existing = await this.
|
|
1406
|
+
async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1407
|
+
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1408
|
+
const existing = await this.getComment(commentId);
|
|
1260
1409
|
if (!existing) return null;
|
|
1410
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1261
1411
|
|
|
1262
1412
|
let updated: Comment | null;
|
|
1263
1413
|
if (this.store.removeReaction) {
|
|
@@ -1288,7 +1438,9 @@ export class CriticalPathEngine {
|
|
|
1288
1438
|
|
|
1289
1439
|
// --- Attachments ---
|
|
1290
1440
|
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
|
|
1291
|
-
const attachments = await this.store.getAttachments(filter)
|
|
1441
|
+
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
|
|
1442
|
+
this.projectIdOfAttachment(a)
|
|
1443
|
+
);
|
|
1292
1444
|
if (filter?.artifactType) {
|
|
1293
1445
|
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1294
1446
|
}
|
|
@@ -1296,13 +1448,25 @@ export class CriticalPathEngine {
|
|
|
1296
1448
|
}
|
|
1297
1449
|
|
|
1298
1450
|
async getAttachment(id: string): Promise<Attachment | null> {
|
|
1299
|
-
|
|
1451
|
+
const attachment = await this.store.getAttachment(id);
|
|
1452
|
+
if (!attachment) return null;
|
|
1453
|
+
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1300
1454
|
}
|
|
1301
1455
|
|
|
1302
|
-
async
|
|
1303
|
-
if (this.
|
|
1304
|
-
|
|
1305
|
-
|
|
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);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
async createAttachment(
|
|
1464
|
+
input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
|
|
1465
|
+
): Promise<Attachment> {
|
|
1466
|
+
await this.requireAttachmentCreate(input);
|
|
1467
|
+
const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1468
|
+
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1469
|
+
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
1306
1470
|
validateAttachmentUrl(attachment.url);
|
|
1307
1471
|
const created = await this.store.createAttachment(attachment);
|
|
1308
1472
|
const now = new Date().toISOString();
|
|
@@ -1322,8 +1486,12 @@ export class CriticalPathEngine {
|
|
|
1322
1486
|
}
|
|
1323
1487
|
|
|
1324
1488
|
async deleteAttachment(id: string): Promise<boolean> {
|
|
1325
|
-
const existing = await this.
|
|
1489
|
+
const existing = await this.getAttachment(id);
|
|
1326
1490
|
if (!existing) return false;
|
|
1491
|
+
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1492
|
+
type: 'attachment',
|
|
1493
|
+
ownerId: existing.uploaderId
|
|
1494
|
+
});
|
|
1327
1495
|
|
|
1328
1496
|
if (existing.storageKey && this.fileStorage) {
|
|
1329
1497
|
try {
|
|
@@ -1356,7 +1524,7 @@ export class CriticalPathEngine {
|
|
|
1356
1524
|
taskId?: string;
|
|
1357
1525
|
projectId?: string;
|
|
1358
1526
|
commentId?: string;
|
|
1359
|
-
uploaderId
|
|
1527
|
+
uploaderId?: string;
|
|
1360
1528
|
uploaderType?: 'user' | 'agent' | 'system';
|
|
1361
1529
|
artifactType?: 'plan' | 'spec' | 'deliverable' | 'review' | 'general';
|
|
1362
1530
|
metadata?: Record<string, unknown>;
|
|
@@ -1365,6 +1533,8 @@ export class CriticalPathEngine {
|
|
|
1365
1533
|
if (!this.fileStorage) {
|
|
1366
1534
|
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1367
1535
|
}
|
|
1536
|
+
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1537
|
+
await this.requireAttachmentCreate(input);
|
|
1368
1538
|
|
|
1369
1539
|
const uploadResult = await this.fileStorage.upload({
|
|
1370
1540
|
filename: input.filename,
|
|
@@ -1422,24 +1592,41 @@ export class CriticalPathEngine {
|
|
|
1422
1592
|
throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
|
|
1423
1593
|
}
|
|
1424
1594
|
|
|
1425
|
-
async getPresignedAttachmentUploadUrl(
|
|
1595
|
+
async getPresignedAttachmentUploadUrl(
|
|
1596
|
+
options: PresignedUrlOptions & { projectId?: string }
|
|
1597
|
+
): Promise<PresignedUploadResult> {
|
|
1426
1598
|
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1427
1599
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1428
1600
|
}
|
|
1429
|
-
|
|
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);
|
|
1430
1614
|
}
|
|
1431
1615
|
|
|
1432
1616
|
// --- Teams ---
|
|
1433
1617
|
async getTeams(): Promise<Team[]> {
|
|
1434
|
-
|
|
1618
|
+
const teams = await this.store.getTeams();
|
|
1619
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1435
1620
|
}
|
|
1436
1621
|
|
|
1437
1622
|
async getTeam(id: string): Promise<Team | null> {
|
|
1438
|
-
|
|
1623
|
+
const team = await this.store.getTeam(id);
|
|
1624
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1439
1625
|
}
|
|
1440
1626
|
|
|
1441
1627
|
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
1442
|
-
|
|
1628
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1629
|
+
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1443
1630
|
const now = new Date().toISOString();
|
|
1444
1631
|
|
|
1445
1632
|
const event: TeamCreatedEvent = {
|
|
@@ -1457,23 +1644,31 @@ export class CriticalPathEngine {
|
|
|
1457
1644
|
}
|
|
1458
1645
|
|
|
1459
1646
|
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
1460
|
-
|
|
1647
|
+
if (!(await this.getTeam(id))) return null;
|
|
1648
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1649
|
+
return this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1461
1650
|
}
|
|
1462
1651
|
|
|
1463
1652
|
async deleteTeam(id: string): Promise<boolean> {
|
|
1653
|
+
if (!(await this.getTeam(id))) return false;
|
|
1654
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1464
1655
|
return this.store.deleteTeam(id);
|
|
1465
1656
|
}
|
|
1466
1657
|
|
|
1467
1658
|
// --- Containers ---
|
|
1468
1659
|
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
1660
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1469
1661
|
return this.store.getContainers(projectId);
|
|
1470
1662
|
}
|
|
1471
1663
|
|
|
1472
1664
|
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
1473
|
-
|
|
1665
|
+
const container = await this.store.getContainer(id);
|
|
1666
|
+
if (!container) return null;
|
|
1667
|
+
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1474
1668
|
}
|
|
1475
1669
|
|
|
1476
1670
|
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
1671
|
+
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1477
1672
|
const created = await this.store.createContainer(container);
|
|
1478
1673
|
const now = new Date().toISOString();
|
|
1479
1674
|
|
|
@@ -1492,23 +1687,33 @@ export class CriticalPathEngine {
|
|
|
1492
1687
|
}
|
|
1493
1688
|
|
|
1494
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);
|
|
1495
1693
|
return this.store.updateContainer(id, updates);
|
|
1496
1694
|
}
|
|
1497
1695
|
|
|
1498
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);
|
|
1499
1700
|
return this.store.deleteContainer(id);
|
|
1500
1701
|
}
|
|
1501
1702
|
|
|
1502
1703
|
// --- Deliverables ---
|
|
1503
1704
|
async getDeliverables(projectId: string): Promise<Deliverable[]> {
|
|
1705
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1504
1706
|
return this.store.getDeliverables(projectId);
|
|
1505
1707
|
}
|
|
1506
1708
|
|
|
1507
1709
|
async getDeliverable(id: string): Promise<Deliverable | null> {
|
|
1508
|
-
|
|
1710
|
+
const deliverable = await this.store.getDeliverable(id);
|
|
1711
|
+
if (!deliverable) return null;
|
|
1712
|
+
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1509
1713
|
}
|
|
1510
1714
|
|
|
1511
1715
|
async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
|
|
1716
|
+
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1512
1717
|
const project = await this.store.getProject(input.projectId);
|
|
1513
1718
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1514
1719
|
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
@@ -1543,8 +1748,9 @@ export class CriticalPathEngine {
|
|
|
1543
1748
|
}
|
|
1544
1749
|
|
|
1545
1750
|
async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
|
|
1546
|
-
const existing = await this.
|
|
1751
|
+
const existing = await this.getDeliverable(id);
|
|
1547
1752
|
if (!existing) return null;
|
|
1753
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1548
1754
|
|
|
1549
1755
|
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1550
1756
|
updates.deliveredAt = new Date().toISOString();
|
|
@@ -1597,8 +1803,9 @@ export class CriticalPathEngine {
|
|
|
1597
1803
|
}
|
|
1598
1804
|
|
|
1599
1805
|
async deleteDeliverable(id: string): Promise<boolean> {
|
|
1600
|
-
const existing = await this.
|
|
1806
|
+
const existing = await this.getDeliverable(id);
|
|
1601
1807
|
if (!existing) return false;
|
|
1808
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1602
1809
|
|
|
1603
1810
|
const deleted = await this.store.deleteDeliverable(id);
|
|
1604
1811
|
if (deleted) {
|
|
@@ -1625,7 +1832,7 @@ export class CriticalPathEngine {
|
|
|
1625
1832
|
}
|
|
1626
1833
|
|
|
1627
1834
|
async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
|
|
1628
|
-
const deliverable = await this.
|
|
1835
|
+
const deliverable = await this.getDeliverable(deliverableId);
|
|
1629
1836
|
if (!deliverable) return null;
|
|
1630
1837
|
|
|
1631
1838
|
const project = await this.store.getProject(deliverable.projectId);
|
|
@@ -1673,14 +1880,18 @@ export class CriticalPathEngine {
|
|
|
1673
1880
|
|
|
1674
1881
|
// --- Iterations ---
|
|
1675
1882
|
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
1883
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1676
1884
|
return this.store.getIterations(projectId);
|
|
1677
1885
|
}
|
|
1678
1886
|
|
|
1679
1887
|
async getIteration(id: string): Promise<Iteration | null> {
|
|
1680
|
-
|
|
1888
|
+
const iteration = await this.store.getIteration(id);
|
|
1889
|
+
if (!iteration) return null;
|
|
1890
|
+
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
1681
1891
|
}
|
|
1682
1892
|
|
|
1683
1893
|
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
1894
|
+
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
1684
1895
|
const created = await this.store.createIteration(iteration);
|
|
1685
1896
|
if (created.status === 'active') {
|
|
1686
1897
|
const now = new Date().toISOString();
|
|
@@ -1699,8 +1910,9 @@ export class CriticalPathEngine {
|
|
|
1699
1910
|
}
|
|
1700
1911
|
|
|
1701
1912
|
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
1702
|
-
const existing = await this.
|
|
1913
|
+
const existing = await this.getIteration(id);
|
|
1703
1914
|
if (!existing) return null;
|
|
1915
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1704
1916
|
|
|
1705
1917
|
const updated = await this.store.updateIteration(id, updates);
|
|
1706
1918
|
if (updated) {
|
|
@@ -1733,6 +1945,9 @@ export class CriticalPathEngine {
|
|
|
1733
1945
|
}
|
|
1734
1946
|
|
|
1735
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);
|
|
1736
1951
|
return this.store.deleteIteration(id);
|
|
1737
1952
|
}
|
|
1738
1953
|
|
|
@@ -1741,6 +1956,7 @@ export class CriticalPathEngine {
|
|
|
1741
1956
|
projectId: string,
|
|
1742
1957
|
options: CPMOptions = {}
|
|
1743
1958
|
): Promise<CriticalPathAnalysis> {
|
|
1959
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1744
1960
|
const project = await this.store.getProject(projectId);
|
|
1745
1961
|
const tasks = await this.store.getTasks(projectId);
|
|
1746
1962
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1763,6 +1979,7 @@ export class CriticalPathEngine {
|
|
|
1763
1979
|
projectId: string,
|
|
1764
1980
|
options: TimelineLadderOptions = {}
|
|
1765
1981
|
): Promise<TimelineLadder> {
|
|
1982
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1766
1983
|
const project = await this.store.getProject(projectId);
|
|
1767
1984
|
if (!project) {
|
|
1768
1985
|
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
@@ -1819,7 +2036,7 @@ export class CriticalPathEngine {
|
|
|
1819
2036
|
}
|
|
1820
2037
|
|
|
1821
2038
|
async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
|
|
1822
|
-
const task = await this.
|
|
2039
|
+
const task = await this.getTask(taskId);
|
|
1823
2040
|
if (!task) return null;
|
|
1824
2041
|
|
|
1825
2042
|
const project = await this.store.getProject(task.projectId);
|
|
@@ -1843,7 +2060,7 @@ export class CriticalPathEngine {
|
|
|
1843
2060
|
}
|
|
1844
2061
|
|
|
1845
2062
|
async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
|
|
1846
|
-
const task = await this.
|
|
2063
|
+
const task = await this.getTask(taskId);
|
|
1847
2064
|
if (!task) return null;
|
|
1848
2065
|
|
|
1849
2066
|
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
@@ -1862,7 +2079,7 @@ export class CriticalPathEngine {
|
|
|
1862
2079
|
}
|
|
1863
2080
|
|
|
1864
2081
|
async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
|
|
1865
|
-
const task = await this.
|
|
2082
|
+
const task = await this.getTask(taskId);
|
|
1866
2083
|
if (!task) return null;
|
|
1867
2084
|
|
|
1868
2085
|
const [activities, project, workflow] = await Promise.all([
|
|
@@ -1883,8 +2100,9 @@ export class CriticalPathEngine {
|
|
|
1883
2100
|
projectId?: string,
|
|
1884
2101
|
options: WorkloadDistributionOptions = {}
|
|
1885
2102
|
): Promise<WorkloadDistribution> {
|
|
1886
|
-
|
|
1887
|
-
const
|
|
2103
|
+
if (projectId) await this.requireProjectAccess('project.read', projectId);
|
|
2104
|
+
const tasks = await this.getTasks(projectId);
|
|
2105
|
+
const teams = await this.getTeams();
|
|
1888
2106
|
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1889
2107
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1890
2108
|
|