@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/dist/engine/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { DomainEventBus } from '../domain/events.js';
|
|
|
7
7
|
import { validateAttachmentUrl } from '../domain/entities.js';
|
|
8
8
|
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
9
9
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
10
|
-
import { ValidationError, NotFoundError } from '../domain/errors.js';
|
|
10
|
+
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
11
11
|
import { calculateCPM } from '../domain/cpm.js';
|
|
12
12
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
13
13
|
import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
|
|
@@ -21,6 +21,8 @@ export class CriticalPathEngine {
|
|
|
21
21
|
ready = Promise.resolve();
|
|
22
22
|
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
23
23
|
actor;
|
|
24
|
+
/** Set on internal views that keep the actor for attribution but skip authorization. */
|
|
25
|
+
authorizationBypassed;
|
|
24
26
|
constructor(config = {}) {
|
|
25
27
|
this.config = config;
|
|
26
28
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
@@ -53,6 +55,102 @@ export class CriticalPathEngine {
|
|
|
53
55
|
actorIdOr(fallback) {
|
|
54
56
|
return this.actor?.userId ?? fallback;
|
|
55
57
|
}
|
|
58
|
+
// --- Authorization (applies only to withActor views; the base engine is trusted) ---
|
|
59
|
+
/**
|
|
60
|
+
* True when calls must be checked: on a withActor view (not an internal elevated view) with a
|
|
61
|
+
* policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
|
|
62
|
+
*/
|
|
63
|
+
get enforcing() {
|
|
64
|
+
return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
|
|
65
|
+
}
|
|
66
|
+
/** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
|
|
67
|
+
elevated() {
|
|
68
|
+
if (!this.enforcing)
|
|
69
|
+
return this;
|
|
70
|
+
const view = Object.create(this);
|
|
71
|
+
Object.defineProperty(view, 'authorizationBypassed', { value: true });
|
|
72
|
+
return view;
|
|
73
|
+
}
|
|
74
|
+
inActorTenant(entity) {
|
|
75
|
+
const tenant = this.actor?.tenantId;
|
|
76
|
+
return !tenant || entity?.tenantId === tenant;
|
|
77
|
+
}
|
|
78
|
+
async isAllowed(action, project, resource) {
|
|
79
|
+
if (!this.enforcing)
|
|
80
|
+
return true;
|
|
81
|
+
if (project && !this.inActorTenant(project))
|
|
82
|
+
return false;
|
|
83
|
+
const policy = this.config.authorize;
|
|
84
|
+
return policy ? await policy({ actor: this.actor, action, project, resource }) : true;
|
|
85
|
+
}
|
|
86
|
+
/** The project if the actor may read it, otherwise null (callers report "not found"). */
|
|
87
|
+
async readableProject(projectId) {
|
|
88
|
+
if (!projectId)
|
|
89
|
+
return null;
|
|
90
|
+
const project = await this.store.getProject(projectId);
|
|
91
|
+
if (!project)
|
|
92
|
+
return null;
|
|
93
|
+
return (await this.isAllowed('project.read', project)) ? project : null;
|
|
94
|
+
}
|
|
95
|
+
async canReadProject(projectId) {
|
|
96
|
+
if (!this.enforcing)
|
|
97
|
+
return true;
|
|
98
|
+
return (await this.readableProject(projectId)) !== null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
|
|
102
|
+
* that belong to another tenant) are reported as not found so their existence is not revealed.
|
|
103
|
+
*/
|
|
104
|
+
async requireProjectAccess(action, projectId, resource) {
|
|
105
|
+
if (!this.enforcing)
|
|
106
|
+
return;
|
|
107
|
+
const project = await this.readableProject(projectId);
|
|
108
|
+
if (!project)
|
|
109
|
+
throw new NotFoundError(`Project "${projectId}" not found.`);
|
|
110
|
+
if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
|
|
111
|
+
throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async requireWorkspaceAccess(action) {
|
|
115
|
+
if (!(await this.isAllowed(action)))
|
|
116
|
+
throw new ForbiddenError(`Not allowed to ${action}.`);
|
|
117
|
+
}
|
|
118
|
+
/** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
|
|
119
|
+
async filterReadable(items, projectIdOf) {
|
|
120
|
+
if (!this.enforcing)
|
|
121
|
+
return items;
|
|
122
|
+
const readable = new Map();
|
|
123
|
+
const result = [];
|
|
124
|
+
for (const item of items) {
|
|
125
|
+
const projectId = await projectIdOf(item);
|
|
126
|
+
if (!projectId)
|
|
127
|
+
continue;
|
|
128
|
+
if (!readable.has(projectId))
|
|
129
|
+
readable.set(projectId, await this.canReadProject(projectId));
|
|
130
|
+
if (readable.get(projectId))
|
|
131
|
+
result.push(item);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
async projectIdOfTask(taskId) {
|
|
136
|
+
return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
|
|
137
|
+
}
|
|
138
|
+
async projectIdOfComment(commentId) {
|
|
139
|
+
if (!commentId)
|
|
140
|
+
return undefined;
|
|
141
|
+
const comment = await this.store.getComment(commentId);
|
|
142
|
+
return this.projectIdOfTask(comment?.taskId);
|
|
143
|
+
}
|
|
144
|
+
async projectIdOfAttachment(a) {
|
|
145
|
+
return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
|
|
146
|
+
}
|
|
147
|
+
/** Strips fields only the engine may set (tenancy) from updates made through a view. */
|
|
148
|
+
withoutTenant(updates) {
|
|
149
|
+
if (!this.actor || !('tenantId' in updates))
|
|
150
|
+
return updates;
|
|
151
|
+
const { tenantId: _ignored, ...rest } = updates;
|
|
152
|
+
return rest;
|
|
153
|
+
}
|
|
56
154
|
async seedInitialData(data) {
|
|
57
155
|
if (data.workflows) {
|
|
58
156
|
for (const wf of data.workflows) {
|
|
@@ -92,13 +190,16 @@ export class CriticalPathEngine {
|
|
|
92
190
|
}
|
|
93
191
|
// --- Workflows ---
|
|
94
192
|
async getWorkflows() {
|
|
95
|
-
|
|
193
|
+
const workflows = await this.store.getWorkflows();
|
|
194
|
+
return workflows.filter((w) => this.inActorTenant(w));
|
|
96
195
|
}
|
|
97
196
|
async getWorkflow(id) {
|
|
98
|
-
|
|
197
|
+
const workflow = await this.store.getWorkflow(id);
|
|
198
|
+
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
99
199
|
}
|
|
100
200
|
async createWorkflow(workflow) {
|
|
101
|
-
|
|
201
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
202
|
+
const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
|
|
102
203
|
const now = new Date().toISOString();
|
|
103
204
|
const event = {
|
|
104
205
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -118,9 +219,11 @@ export class CriticalPathEngine {
|
|
|
118
219
|
return created;
|
|
119
220
|
}
|
|
120
221
|
async updateWorkflow(id, updates) {
|
|
121
|
-
const existing = await this.
|
|
222
|
+
const existing = await this.getWorkflow(id);
|
|
122
223
|
if (!existing)
|
|
123
224
|
return null;
|
|
225
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
226
|
+
updates = this.withoutTenant(updates);
|
|
124
227
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
125
228
|
if (updated) {
|
|
126
229
|
const now = new Date().toISOString();
|
|
@@ -143,9 +246,10 @@ export class CriticalPathEngine {
|
|
|
143
246
|
return updated;
|
|
144
247
|
}
|
|
145
248
|
async deleteWorkflow(id) {
|
|
146
|
-
const existing = await this.
|
|
249
|
+
const existing = await this.getWorkflow(id);
|
|
147
250
|
if (!existing)
|
|
148
251
|
return false;
|
|
252
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
149
253
|
const deleted = await this.store.deleteWorkflow(id);
|
|
150
254
|
if (deleted) {
|
|
151
255
|
const now = new Date().toISOString();
|
|
@@ -176,7 +280,8 @@ export class CriticalPathEngine {
|
|
|
176
280
|
if (wf)
|
|
177
281
|
return wf;
|
|
178
282
|
}
|
|
179
|
-
|
|
283
|
+
// Only fall back to workflows from the project's own tenant.
|
|
284
|
+
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
|
|
180
285
|
const defaultWf = workflows.find((w) => w.isDefault);
|
|
181
286
|
if (defaultWf)
|
|
182
287
|
return defaultWf;
|
|
@@ -185,21 +290,21 @@ export class CriticalPathEngine {
|
|
|
185
290
|
return DEFAULT_SOFTWARE_WORKFLOW;
|
|
186
291
|
}
|
|
187
292
|
async getAllowedTaskTransitions(taskId) {
|
|
188
|
-
const task = await this.
|
|
293
|
+
const task = await this.getTask(taskId);
|
|
189
294
|
if (!task)
|
|
190
295
|
return [];
|
|
191
296
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
192
297
|
return getAllowedTransitions(workflow || undefined, task.status);
|
|
193
298
|
}
|
|
194
299
|
async getAllowedNextTaskTransitions(taskId) {
|
|
195
|
-
const task = await this.
|
|
300
|
+
const task = await this.getTask(taskId);
|
|
196
301
|
if (!task)
|
|
197
302
|
return [];
|
|
198
303
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
199
304
|
return getAllowedNextStatuses(workflow || undefined, task.status);
|
|
200
305
|
}
|
|
201
306
|
async getAllowedPreviousTaskTransitions(taskId) {
|
|
202
|
-
const task = await this.
|
|
307
|
+
const task = await this.getTask(taskId);
|
|
203
308
|
if (!task)
|
|
204
309
|
return [];
|
|
205
310
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
@@ -207,13 +312,23 @@ export class CriticalPathEngine {
|
|
|
207
312
|
}
|
|
208
313
|
// --- Projects ---
|
|
209
314
|
async getProjects() {
|
|
210
|
-
|
|
315
|
+
const projects = await this.store.getProjects();
|
|
316
|
+
return this.filterReadable(projects, (p) => p.id);
|
|
211
317
|
}
|
|
212
318
|
async getProject(id) {
|
|
213
|
-
|
|
319
|
+
if (!this.enforcing)
|
|
320
|
+
return this.store.getProject(id);
|
|
321
|
+
return this.readableProject(id);
|
|
214
322
|
}
|
|
215
323
|
async createProject(project) {
|
|
216
|
-
|
|
324
|
+
await this.requireWorkspaceAccess('project.create');
|
|
325
|
+
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
326
|
+
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
327
|
+
// The creator administers the project they create.
|
|
328
|
+
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
|
|
329
|
+
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
330
|
+
}
|
|
331
|
+
const created = await this.store.createProject(input);
|
|
217
332
|
const now = new Date().toISOString();
|
|
218
333
|
const event = {
|
|
219
334
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -234,9 +349,13 @@ export class CriticalPathEngine {
|
|
|
234
349
|
return created;
|
|
235
350
|
}
|
|
236
351
|
async updateProject(id, updates) {
|
|
237
|
-
const existing = await this.
|
|
352
|
+
const existing = await this.getProject(id);
|
|
238
353
|
if (!existing)
|
|
239
354
|
return null;
|
|
355
|
+
await this.requireProjectAccess('project.update', id);
|
|
356
|
+
if ('members' in updates)
|
|
357
|
+
await this.requireProjectAccess('project.manage_members', id);
|
|
358
|
+
updates = this.withoutTenant(updates);
|
|
240
359
|
const updated = await this.store.updateProject(id, updates);
|
|
241
360
|
if (updated) {
|
|
242
361
|
const now = new Date().toISOString();
|
|
@@ -264,13 +383,16 @@ export class CriticalPathEngine {
|
|
|
264
383
|
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
265
384
|
*/
|
|
266
385
|
async deleteProject(id) {
|
|
267
|
-
const existing = await this.
|
|
386
|
+
const existing = await this.getProject(id);
|
|
268
387
|
if (!existing)
|
|
269
388
|
return false;
|
|
389
|
+
await this.requireProjectAccess('project.delete', id);
|
|
390
|
+
// Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
|
|
391
|
+
const cascade = this.elevated();
|
|
270
392
|
const tasks = await this.store.getTasks(id);
|
|
271
393
|
const deletedTaskIds = [];
|
|
272
394
|
for (const task of tasks) {
|
|
273
|
-
if (await
|
|
395
|
+
if (await cascade.deleteTask(task.id)) {
|
|
274
396
|
deletedTaskIds.push(task.id);
|
|
275
397
|
}
|
|
276
398
|
}
|
|
@@ -298,12 +420,19 @@ export class CriticalPathEngine {
|
|
|
298
420
|
}
|
|
299
421
|
// --- Tasks ---
|
|
300
422
|
async getTasks(projectId) {
|
|
301
|
-
|
|
423
|
+
if (projectId && !(await this.canReadProject(projectId)))
|
|
424
|
+
return [];
|
|
425
|
+
const tasks = await this.store.getTasks(projectId);
|
|
426
|
+
return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
|
|
302
427
|
}
|
|
303
428
|
async getTask(id) {
|
|
304
|
-
|
|
429
|
+
const task = await this.store.getTask(id);
|
|
430
|
+
if (!task)
|
|
431
|
+
return null;
|
|
432
|
+
return (await this.canReadProject(task.projectId)) ? task : null;
|
|
305
433
|
}
|
|
306
434
|
async createTask(taskInput) {
|
|
435
|
+
await this.requireProjectAccess('task.create', taskInput.projectId);
|
|
307
436
|
if (this.actor && !taskInput.reporterId) {
|
|
308
437
|
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
309
438
|
}
|
|
@@ -389,34 +518,17 @@ export class CriticalPathEngine {
|
|
|
389
518
|
return created;
|
|
390
519
|
}
|
|
391
520
|
async updateTask(id, updates, options) {
|
|
392
|
-
const existing = await this.
|
|
521
|
+
const existing = await this.getTask(id);
|
|
393
522
|
if (!existing)
|
|
394
523
|
return null;
|
|
395
|
-
|
|
396
|
-
//
|
|
524
|
+
await this.requireProjectAccess('task.update', existing.projectId);
|
|
525
|
+
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
397
526
|
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
'system';
|
|
404
|
-
const actorName = options?.actorName ||
|
|
405
|
-
claimed?.actorName ||
|
|
406
|
-
options?.actor?.username ||
|
|
407
|
-
claimed?.actor?.username ||
|
|
408
|
-
(actorId === 'system' ? 'System' : undefined);
|
|
409
|
-
const actorType = options?.actorType ||
|
|
410
|
-
claimed?.actorType ||
|
|
411
|
-
options?.actor?.actorType ||
|
|
412
|
-
claimed?.actor?.actorType ||
|
|
413
|
-
(actorId === 'system' ? 'system' : 'user');
|
|
414
|
-
const actorObj = options?.actor || claimed?.actor || {
|
|
415
|
-
userId: actorId,
|
|
416
|
-
username: actorName,
|
|
417
|
-
actorType
|
|
418
|
-
};
|
|
419
|
-
const { actorId: _aId, actorName: _aName, actorType: _aType, actor: _aObj, ...taskUpdates } = updates;
|
|
527
|
+
const actorId = options?.actorId || options?.actor?.userId || 'system';
|
|
528
|
+
const actorName = options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
|
|
529
|
+
const actorType = options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
|
|
530
|
+
const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
|
|
531
|
+
const taskUpdates = updates;
|
|
420
532
|
const project = await this.store.getProject(existing.projectId);
|
|
421
533
|
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
422
534
|
// Validate workflow transition invariant
|
|
@@ -766,9 +878,10 @@ export class CriticalPathEngine {
|
|
|
766
878
|
}
|
|
767
879
|
}
|
|
768
880
|
async deleteTask(id) {
|
|
769
|
-
const existing = await this.
|
|
881
|
+
const existing = await this.getTask(id);
|
|
770
882
|
if (!existing)
|
|
771
883
|
return false;
|
|
884
|
+
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
772
885
|
await this.plugins.runBeforeTaskDelete(id);
|
|
773
886
|
const deleted = await this.store.deleteTask(id);
|
|
774
887
|
if (deleted) {
|
|
@@ -800,6 +913,14 @@ export class CriticalPathEngine {
|
|
|
800
913
|
}
|
|
801
914
|
// --- Task Dependencies & Graph ---
|
|
802
915
|
async addDependency(dep) {
|
|
916
|
+
if (this.enforcing) {
|
|
917
|
+
const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
|
|
918
|
+
if (!task)
|
|
919
|
+
throw new NotFoundError(`Task "${dep.taskId}" not found.`);
|
|
920
|
+
if (!upstream)
|
|
921
|
+
throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
|
|
922
|
+
await this.requireProjectAccess('task.update', task.projectId);
|
|
923
|
+
}
|
|
803
924
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
804
925
|
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
805
926
|
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
@@ -848,6 +969,9 @@ export class CriticalPathEngine {
|
|
|
848
969
|
return Array.from(edges.values());
|
|
849
970
|
}
|
|
850
971
|
async getTaskDependencyGraph(taskId) {
|
|
972
|
+
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
973
|
+
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
974
|
+
}
|
|
851
975
|
const dependencies = await this.store.getDependencies(taskId);
|
|
852
976
|
const upstreamTaskIds = new Set();
|
|
853
977
|
const downstreamTaskIds = new Set();
|
|
@@ -859,8 +983,8 @@ export class CriticalPathEngine {
|
|
|
859
983
|
downstreamTaskIds.add(dep.taskId);
|
|
860
984
|
}
|
|
861
985
|
}
|
|
862
|
-
const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.
|
|
863
|
-
const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
986
|
+
const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
|
|
987
|
+
const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
|
|
864
988
|
return {
|
|
865
989
|
taskId,
|
|
866
990
|
upstreamTasks,
|
|
@@ -889,7 +1013,12 @@ export class CriticalPathEngine {
|
|
|
889
1013
|
if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
|
|
890
1014
|
throw new ValidationError('Logged hours must be a positive number.');
|
|
891
1015
|
}
|
|
892
|
-
const task = await this.
|
|
1016
|
+
const task = await this.getTask(entry.taskId);
|
|
1017
|
+
if (this.enforcing) {
|
|
1018
|
+
if (!task)
|
|
1019
|
+
throw new NotFoundError(`Task "${entry.taskId}" not found.`);
|
|
1020
|
+
await this.requireProjectAccess('time.log', task.projectId);
|
|
1021
|
+
}
|
|
893
1022
|
if (task) {
|
|
894
1023
|
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
895
1024
|
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
@@ -920,15 +1049,35 @@ export class CriticalPathEngine {
|
|
|
920
1049
|
}
|
|
921
1050
|
// --- Comments ---
|
|
922
1051
|
async getComments(taskId) {
|
|
1052
|
+
if (this.enforcing && !(await this.getTask(taskId)))
|
|
1053
|
+
return [];
|
|
923
1054
|
return this.store.getComments(taskId);
|
|
924
1055
|
}
|
|
925
1056
|
async getComment(id) {
|
|
926
|
-
|
|
1057
|
+
const comment = await this.store.getComment(id);
|
|
1058
|
+
if (!comment)
|
|
1059
|
+
return null;
|
|
1060
|
+
return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
|
|
927
1061
|
}
|
|
928
|
-
async
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1062
|
+
async getActivities(filter) {
|
|
1063
|
+
const activities = await this.store.getActivities(filter);
|
|
1064
|
+
return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
|
|
1065
|
+
}
|
|
1066
|
+
async getTimeEntries(taskId) {
|
|
1067
|
+
if (this.enforcing && !(await this.getTask(taskId)))
|
|
1068
|
+
return [];
|
|
1069
|
+
return this.store.getTimeEntries(taskId);
|
|
1070
|
+
}
|
|
1071
|
+
async addComment(input) {
|
|
1072
|
+
if (this.enforcing) {
|
|
1073
|
+
const task = await this.getTask(input.taskId);
|
|
1074
|
+
if (!task)
|
|
1075
|
+
throw new NotFoundError(`Task "${input.taskId}" not found.`);
|
|
1076
|
+
await this.requireProjectAccess('comment.create', task.projectId);
|
|
1077
|
+
}
|
|
1078
|
+
const comment = this.actor
|
|
1079
|
+
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
1080
|
+
: { ...input, authorId: input.authorId ?? 'system' };
|
|
932
1081
|
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
933
1082
|
const created = await this.store.addComment({ ...comment, mentions });
|
|
934
1083
|
const now = new Date().toISOString();
|
|
@@ -945,9 +1094,13 @@ export class CriticalPathEngine {
|
|
|
945
1094
|
return created;
|
|
946
1095
|
}
|
|
947
1096
|
async updateComment(id, updates) {
|
|
948
|
-
const existing = await this.
|
|
1097
|
+
const existing = await this.getComment(id);
|
|
949
1098
|
if (!existing)
|
|
950
1099
|
return null;
|
|
1100
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1101
|
+
type: 'comment',
|
|
1102
|
+
ownerId: existing.authorId
|
|
1103
|
+
});
|
|
951
1104
|
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
952
1105
|
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
953
1106
|
const updated = await this.store.updateComment(id, toUpdate);
|
|
@@ -967,9 +1120,13 @@ export class CriticalPathEngine {
|
|
|
967
1120
|
return updated;
|
|
968
1121
|
}
|
|
969
1122
|
async deleteComment(id) {
|
|
970
|
-
const existing = await this.
|
|
1123
|
+
const existing = await this.getComment(id);
|
|
971
1124
|
if (!existing)
|
|
972
1125
|
return false;
|
|
1126
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1127
|
+
type: 'comment',
|
|
1128
|
+
ownerId: existing.authorId
|
|
1129
|
+
});
|
|
973
1130
|
const deleted = await this.store.deleteComment(id);
|
|
974
1131
|
if (deleted) {
|
|
975
1132
|
const now = new Date().toISOString();
|
|
@@ -986,12 +1143,12 @@ export class CriticalPathEngine {
|
|
|
986
1143
|
}
|
|
987
1144
|
return deleted;
|
|
988
1145
|
}
|
|
989
|
-
async addCommentReaction(commentId,
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const existing = await this.store.getComment(commentId);
|
|
1146
|
+
async addCommentReaction(commentId, input) {
|
|
1147
|
+
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1148
|
+
const existing = await this.getComment(commentId);
|
|
993
1149
|
if (!existing)
|
|
994
1150
|
return null;
|
|
1151
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
995
1152
|
let updated;
|
|
996
1153
|
if (this.store.addReaction) {
|
|
997
1154
|
updated = await this.store.addReaction(commentId, reaction);
|
|
@@ -1031,12 +1188,12 @@ export class CriticalPathEngine {
|
|
|
1031
1188
|
this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
|
|
1032
1189
|
return updated;
|
|
1033
1190
|
}
|
|
1034
|
-
async removeCommentReaction(commentId,
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const existing = await this.store.getComment(commentId);
|
|
1191
|
+
async removeCommentReaction(commentId, input) {
|
|
1192
|
+
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1193
|
+
const existing = await this.getComment(commentId);
|
|
1038
1194
|
if (!existing)
|
|
1039
1195
|
return null;
|
|
1196
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1040
1197
|
let updated;
|
|
1041
1198
|
if (this.store.removeReaction) {
|
|
1042
1199
|
updated = await this.store.removeReaction(commentId, reaction);
|
|
@@ -1062,19 +1219,31 @@ export class CriticalPathEngine {
|
|
|
1062
1219
|
}
|
|
1063
1220
|
// --- Attachments ---
|
|
1064
1221
|
async getAttachments(filter) {
|
|
1065
|
-
const attachments = await this.store.getAttachments(filter);
|
|
1222
|
+
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) => this.projectIdOfAttachment(a));
|
|
1066
1223
|
if (filter?.artifactType) {
|
|
1067
1224
|
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1068
1225
|
}
|
|
1069
1226
|
return attachments;
|
|
1070
1227
|
}
|
|
1071
1228
|
async getAttachment(id) {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1229
|
+
const attachment = await this.store.getAttachment(id);
|
|
1230
|
+
if (!attachment)
|
|
1231
|
+
return null;
|
|
1232
|
+
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1233
|
+
}
|
|
1234
|
+
async requireAttachmentCreate(link) {
|
|
1235
|
+
if (!this.enforcing)
|
|
1236
|
+
return;
|
|
1237
|
+
const projectId = await this.projectIdOfAttachment(link);
|
|
1238
|
+
if (!projectId)
|
|
1239
|
+
throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1240
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1241
|
+
}
|
|
1242
|
+
async createAttachment(input) {
|
|
1243
|
+
await this.requireAttachmentCreate(input);
|
|
1244
|
+
const attachment = this.actor
|
|
1245
|
+
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1246
|
+
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
1078
1247
|
validateAttachmentUrl(attachment.url);
|
|
1079
1248
|
const created = await this.store.createAttachment(attachment);
|
|
1080
1249
|
const now = new Date().toISOString();
|
|
@@ -1091,9 +1260,13 @@ export class CriticalPathEngine {
|
|
|
1091
1260
|
return created;
|
|
1092
1261
|
}
|
|
1093
1262
|
async deleteAttachment(id) {
|
|
1094
|
-
const existing = await this.
|
|
1263
|
+
const existing = await this.getAttachment(id);
|
|
1095
1264
|
if (!existing)
|
|
1096
1265
|
return false;
|
|
1266
|
+
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1267
|
+
type: 'attachment',
|
|
1268
|
+
ownerId: existing.uploaderId
|
|
1269
|
+
});
|
|
1097
1270
|
if (existing.storageKey && this.fileStorage) {
|
|
1098
1271
|
try {
|
|
1099
1272
|
await this.fileStorage.delete(existing.storageKey);
|
|
@@ -1122,6 +1295,8 @@ export class CriticalPathEngine {
|
|
|
1122
1295
|
if (!this.fileStorage) {
|
|
1123
1296
|
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1124
1297
|
}
|
|
1298
|
+
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1299
|
+
await this.requireAttachmentCreate(input);
|
|
1125
1300
|
const uploadResult = await this.fileStorage.upload({
|
|
1126
1301
|
filename: input.filename,
|
|
1127
1302
|
data: input.data,
|
|
@@ -1175,17 +1350,33 @@ export class CriticalPathEngine {
|
|
|
1175
1350
|
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1176
1351
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1177
1352
|
}
|
|
1178
|
-
|
|
1353
|
+
const { projectId, ...presign } = options;
|
|
1354
|
+
if (this.enforcing) {
|
|
1355
|
+
// Scoped callers may only write under their project's prefix.
|
|
1356
|
+
if (!projectId)
|
|
1357
|
+
throw new ValidationError('projectId is required for presigned uploads.');
|
|
1358
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1359
|
+
const prefix = `projects/${projectId}/`;
|
|
1360
|
+
const key = presign.storageKey.replace(/^\/+/, '');
|
|
1361
|
+
if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
1362
|
+
throw new ValidationError('storageKey must not contain "." or ".." segments.');
|
|
1363
|
+
}
|
|
1364
|
+
presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
|
|
1365
|
+
}
|
|
1366
|
+
return this.fileStorage.getPresignedUploadUrl(presign);
|
|
1179
1367
|
}
|
|
1180
1368
|
// --- Teams ---
|
|
1181
1369
|
async getTeams() {
|
|
1182
|
-
|
|
1370
|
+
const teams = await this.store.getTeams();
|
|
1371
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1183
1372
|
}
|
|
1184
1373
|
async getTeam(id) {
|
|
1185
|
-
|
|
1374
|
+
const team = await this.store.getTeam(id);
|
|
1375
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1186
1376
|
}
|
|
1187
1377
|
async createTeam(team) {
|
|
1188
|
-
|
|
1378
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1379
|
+
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1189
1380
|
const now = new Date().toISOString();
|
|
1190
1381
|
const event = {
|
|
1191
1382
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1200,19 +1391,31 @@ export class CriticalPathEngine {
|
|
|
1200
1391
|
return created;
|
|
1201
1392
|
}
|
|
1202
1393
|
async updateTeam(id, updates) {
|
|
1203
|
-
|
|
1394
|
+
if (!(await this.getTeam(id)))
|
|
1395
|
+
return null;
|
|
1396
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1397
|
+
return this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1204
1398
|
}
|
|
1205
1399
|
async deleteTeam(id) {
|
|
1400
|
+
if (!(await this.getTeam(id)))
|
|
1401
|
+
return false;
|
|
1402
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1206
1403
|
return this.store.deleteTeam(id);
|
|
1207
1404
|
}
|
|
1208
1405
|
// --- Containers ---
|
|
1209
1406
|
async getContainers(projectId) {
|
|
1407
|
+
if (!(await this.canReadProject(projectId)))
|
|
1408
|
+
return [];
|
|
1210
1409
|
return this.store.getContainers(projectId);
|
|
1211
1410
|
}
|
|
1212
1411
|
async getContainer(id) {
|
|
1213
|
-
|
|
1412
|
+
const container = await this.store.getContainer(id);
|
|
1413
|
+
if (!container)
|
|
1414
|
+
return null;
|
|
1415
|
+
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1214
1416
|
}
|
|
1215
1417
|
async createContainer(container) {
|
|
1418
|
+
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1216
1419
|
const created = await this.store.createContainer(container);
|
|
1217
1420
|
const now = new Date().toISOString();
|
|
1218
1421
|
const event = {
|
|
@@ -1228,19 +1431,33 @@ export class CriticalPathEngine {
|
|
|
1228
1431
|
return created;
|
|
1229
1432
|
}
|
|
1230
1433
|
async updateContainer(id, updates) {
|
|
1434
|
+
const existing = await this.getContainer(id);
|
|
1435
|
+
if (!existing)
|
|
1436
|
+
return null;
|
|
1437
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1231
1438
|
return this.store.updateContainer(id, updates);
|
|
1232
1439
|
}
|
|
1233
1440
|
async deleteContainer(id) {
|
|
1441
|
+
const existing = await this.getContainer(id);
|
|
1442
|
+
if (!existing)
|
|
1443
|
+
return false;
|
|
1444
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1234
1445
|
return this.store.deleteContainer(id);
|
|
1235
1446
|
}
|
|
1236
1447
|
// --- Deliverables ---
|
|
1237
1448
|
async getDeliverables(projectId) {
|
|
1449
|
+
if (!(await this.canReadProject(projectId)))
|
|
1450
|
+
return [];
|
|
1238
1451
|
return this.store.getDeliverables(projectId);
|
|
1239
1452
|
}
|
|
1240
1453
|
async getDeliverable(id) {
|
|
1241
|
-
|
|
1454
|
+
const deliverable = await this.store.getDeliverable(id);
|
|
1455
|
+
if (!deliverable)
|
|
1456
|
+
return null;
|
|
1457
|
+
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1242
1458
|
}
|
|
1243
1459
|
async createDeliverable(input) {
|
|
1460
|
+
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1244
1461
|
const project = await this.store.getProject(input.projectId);
|
|
1245
1462
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1246
1463
|
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
@@ -1271,9 +1488,10 @@ export class CriticalPathEngine {
|
|
|
1271
1488
|
return created;
|
|
1272
1489
|
}
|
|
1273
1490
|
async updateDeliverable(id, updates) {
|
|
1274
|
-
const existing = await this.
|
|
1491
|
+
const existing = await this.getDeliverable(id);
|
|
1275
1492
|
if (!existing)
|
|
1276
1493
|
return null;
|
|
1494
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1277
1495
|
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1278
1496
|
updates.deliveredAt = new Date().toISOString();
|
|
1279
1497
|
}
|
|
@@ -1320,9 +1538,10 @@ export class CriticalPathEngine {
|
|
|
1320
1538
|
return updated;
|
|
1321
1539
|
}
|
|
1322
1540
|
async deleteDeliverable(id) {
|
|
1323
|
-
const existing = await this.
|
|
1541
|
+
const existing = await this.getDeliverable(id);
|
|
1324
1542
|
if (!existing)
|
|
1325
1543
|
return false;
|
|
1544
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1326
1545
|
const deleted = await this.store.deleteDeliverable(id);
|
|
1327
1546
|
if (deleted) {
|
|
1328
1547
|
const now = new Date().toISOString();
|
|
@@ -1346,7 +1565,7 @@ export class CriticalPathEngine {
|
|
|
1346
1565
|
return deleted;
|
|
1347
1566
|
}
|
|
1348
1567
|
async getDeliverableSummary(deliverableId) {
|
|
1349
|
-
const deliverable = await this.
|
|
1568
|
+
const deliverable = await this.getDeliverable(deliverableId);
|
|
1350
1569
|
if (!deliverable)
|
|
1351
1570
|
return null;
|
|
1352
1571
|
const project = await this.store.getProject(deliverable.projectId);
|
|
@@ -1391,12 +1610,18 @@ export class CriticalPathEngine {
|
|
|
1391
1610
|
}
|
|
1392
1611
|
// --- Iterations ---
|
|
1393
1612
|
async getIterations(projectId) {
|
|
1613
|
+
if (!(await this.canReadProject(projectId)))
|
|
1614
|
+
return [];
|
|
1394
1615
|
return this.store.getIterations(projectId);
|
|
1395
1616
|
}
|
|
1396
1617
|
async getIteration(id) {
|
|
1397
|
-
|
|
1618
|
+
const iteration = await this.store.getIteration(id);
|
|
1619
|
+
if (!iteration)
|
|
1620
|
+
return null;
|
|
1621
|
+
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
1398
1622
|
}
|
|
1399
1623
|
async createIteration(iteration) {
|
|
1624
|
+
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
1400
1625
|
const created = await this.store.createIteration(iteration);
|
|
1401
1626
|
if (created.status === 'active') {
|
|
1402
1627
|
const now = new Date().toISOString();
|
|
@@ -1414,9 +1639,10 @@ export class CriticalPathEngine {
|
|
|
1414
1639
|
return created;
|
|
1415
1640
|
}
|
|
1416
1641
|
async updateIteration(id, updates) {
|
|
1417
|
-
const existing = await this.
|
|
1642
|
+
const existing = await this.getIteration(id);
|
|
1418
1643
|
if (!existing)
|
|
1419
1644
|
return null;
|
|
1645
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1420
1646
|
const updated = await this.store.updateIteration(id, updates);
|
|
1421
1647
|
if (updated) {
|
|
1422
1648
|
const now = new Date().toISOString();
|
|
@@ -1448,10 +1674,15 @@ export class CriticalPathEngine {
|
|
|
1448
1674
|
return updated;
|
|
1449
1675
|
}
|
|
1450
1676
|
async deleteIteration(id) {
|
|
1677
|
+
const existing = await this.getIteration(id);
|
|
1678
|
+
if (!existing)
|
|
1679
|
+
return false;
|
|
1680
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1451
1681
|
return this.store.deleteIteration(id);
|
|
1452
1682
|
}
|
|
1453
1683
|
// --- Ladder of Abstraction & Critical Path Method ---
|
|
1454
1684
|
async calculateCriticalPath(projectId, options = {}) {
|
|
1685
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1455
1686
|
const project = await this.store.getProject(projectId);
|
|
1456
1687
|
const tasks = await this.store.getTasks(projectId);
|
|
1457
1688
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1470,6 +1701,7 @@ export class CriticalPathEngine {
|
|
|
1470
1701
|
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
|
|
1471
1702
|
}
|
|
1472
1703
|
async getTimelineLadder(projectId, options = {}) {
|
|
1704
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1473
1705
|
const project = await this.store.getProject(projectId);
|
|
1474
1706
|
if (!project) {
|
|
1475
1707
|
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
@@ -1516,7 +1748,7 @@ export class CriticalPathEngine {
|
|
|
1516
1748
|
}, options);
|
|
1517
1749
|
}
|
|
1518
1750
|
async getTaskLadder(taskId) {
|
|
1519
|
-
const task = await this.
|
|
1751
|
+
const task = await this.getTask(taskId);
|
|
1520
1752
|
if (!task)
|
|
1521
1753
|
return null;
|
|
1522
1754
|
const project = await this.store.getProject(task.projectId);
|
|
@@ -1538,7 +1770,7 @@ export class CriticalPathEngine {
|
|
|
1538
1770
|
};
|
|
1539
1771
|
}
|
|
1540
1772
|
async getTaskMetrics(taskId, options) {
|
|
1541
|
-
const task = await this.
|
|
1773
|
+
const task = await this.getTask(taskId);
|
|
1542
1774
|
if (!task)
|
|
1543
1775
|
return null;
|
|
1544
1776
|
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
@@ -1554,7 +1786,7 @@ export class CriticalPathEngine {
|
|
|
1554
1786
|
});
|
|
1555
1787
|
}
|
|
1556
1788
|
async getTaskProgressHistory(taskId, options) {
|
|
1557
|
-
const task = await this.
|
|
1789
|
+
const task = await this.getTask(taskId);
|
|
1558
1790
|
if (!task)
|
|
1559
1791
|
return null;
|
|
1560
1792
|
const [activities, project, workflow] = await Promise.all([
|
|
@@ -1569,8 +1801,10 @@ export class CriticalPathEngine {
|
|
|
1569
1801
|
});
|
|
1570
1802
|
}
|
|
1571
1803
|
async getWorkloadDistribution(projectId, options = {}) {
|
|
1572
|
-
|
|
1573
|
-
|
|
1804
|
+
if (projectId)
|
|
1805
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1806
|
+
const tasks = await this.getTasks(projectId);
|
|
1807
|
+
const teams = await this.getTeams();
|
|
1574
1808
|
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1575
1809
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1576
1810
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|