@critical-path/core 0.25.0 → 0.27.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 +52 -0
- package/README.md +25 -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/events.d.ts +5 -0
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +34 -0
- package/dist/domain/events.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 +65 -3
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +404 -104
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/index.d.ts +131 -54
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +18 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +1 -0
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/firebase.d.ts +3 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +21 -0
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +17 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +14 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +62 -17
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +61 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/dispatcher.d.ts +80 -0
- package/dist/webhooks/dispatcher.d.ts.map +1 -0
- package/dist/webhooks/dispatcher.js +133 -0
- package/dist/webhooks/dispatcher.js.map +1 -0
- package/dist/webhooks/index.d.ts +3 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +3 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/signature.d.ts +28 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +42 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/webhooks/webhooks.test.d.ts +2 -0
- package/dist/webhooks/webhooks.test.d.ts.map +1 -0
- package/dist/webhooks/webhooks.test.js +153 -0
- package/dist/webhooks/webhooks.test.js.map +1 -0
- 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/events.ts +42 -3
- package/src/domain/index.ts +1 -0
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +419 -108
- package/src/index.ts +1 -0
- package/src/schemas/index.ts +22 -1
- package/src/schemas/schemas.test.ts +9 -5
- package/src/store/firebase.test.ts +20 -0
- package/src/store/firebase.ts +22 -0
- package/src/store/index.ts +19 -0
- package/src/store/sqlite.ts +74 -13
- package/src/types/index.ts +81 -29
- package/src/webhooks/dispatcher.ts +190 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/signature.ts +57 -0
- package/src/webhooks/webhooks.test.ts +200 -0
- 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,
|
|
@@ -15,7 +17,8 @@ import type {
|
|
|
15
17
|
PresignedUrlOptions,
|
|
16
18
|
PresignedUploadResult,
|
|
17
19
|
TimeEntry,
|
|
18
|
-
|
|
20
|
+
Webhook,
|
|
21
|
+
PublicWebhook,
|
|
19
22
|
Workflow,
|
|
20
23
|
CreateTaskInput,
|
|
21
24
|
CreateProjectInput,
|
|
@@ -82,7 +85,10 @@ import {
|
|
|
82
85
|
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
83
86
|
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
84
87
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
85
|
-
import { ValidationError, NotFoundError } from '../domain/errors.js';
|
|
88
|
+
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
89
|
+
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
90
|
+
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
91
|
+
import type { DomainEvent } from '../domain/events.js';
|
|
86
92
|
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
87
93
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
88
94
|
import {
|
|
@@ -98,9 +104,13 @@ export class CriticalPathEngine {
|
|
|
98
104
|
public readonly fileStorage?: FileStorageAdapter;
|
|
99
105
|
public readonly plugins: PluginRegistry;
|
|
100
106
|
public readonly events: DomainEventBus;
|
|
107
|
+
/** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
|
|
108
|
+
public readonly webhooks: WebhookDispatcher;
|
|
101
109
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
102
110
|
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
103
111
|
public readonly actor?: Actor;
|
|
112
|
+
/** Set on internal views that keep the actor for attribution but skip authorization. */
|
|
113
|
+
private readonly authorizationBypassed?: boolean;
|
|
104
114
|
|
|
105
115
|
constructor(config: CriticalPathConfig = {}) {
|
|
106
116
|
this.config = config;
|
|
@@ -111,6 +121,14 @@ export class CriticalPathEngine {
|
|
|
111
121
|
this.fileStorage = config.fileStorage;
|
|
112
122
|
this.plugins = new PluginRegistry();
|
|
113
123
|
this.events = new DomainEventBus();
|
|
124
|
+
this.webhooks = new WebhookDispatcher(
|
|
125
|
+
{
|
|
126
|
+
listWebhooks: () => this.listAllWebhooks(),
|
|
127
|
+
resolveTenant: (event) => this.resolveEventTenant(event)
|
|
128
|
+
},
|
|
129
|
+
config.webhookDelivery
|
|
130
|
+
);
|
|
131
|
+
this.events.subscribe('*', (event) => this.webhooks.handle(event));
|
|
114
132
|
|
|
115
133
|
if (config.plugins) {
|
|
116
134
|
for (const plugin of config.plugins) {
|
|
@@ -140,6 +158,112 @@ export class CriticalPathEngine {
|
|
|
140
158
|
return this.actor?.userId ?? fallback;
|
|
141
159
|
}
|
|
142
160
|
|
|
161
|
+
// --- Authorization (applies only to withActor views; the base engine is trusted) ---
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when calls must be checked: on a withActor view (not an internal elevated view) with a
|
|
165
|
+
* policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
|
|
166
|
+
*/
|
|
167
|
+
private get enforcing(): boolean {
|
|
168
|
+
return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
|
|
172
|
+
private elevated(): CriticalPathEngine {
|
|
173
|
+
if (!this.enforcing) return this;
|
|
174
|
+
const view = Object.create(this) as CriticalPathEngine;
|
|
175
|
+
Object.defineProperty(view, 'authorizationBypassed', { value: true });
|
|
176
|
+
return view;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
|
|
180
|
+
const tenant = this.actor?.tenantId;
|
|
181
|
+
return !tenant || entity?.tenantId === tenant;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async isAllowed(
|
|
185
|
+
action: AuthorizationAction,
|
|
186
|
+
project?: Project,
|
|
187
|
+
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
if (!this.enforcing) return true;
|
|
190
|
+
if (project && !this.inActorTenant(project)) return false;
|
|
191
|
+
const policy = this.config.authorize;
|
|
192
|
+
return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The project if the actor may read it, otherwise null (callers report "not found"). */
|
|
196
|
+
private async readableProject(projectId: string | undefined): Promise<Project | null> {
|
|
197
|
+
if (!projectId) return null;
|
|
198
|
+
const project = await this.store.getProject(projectId);
|
|
199
|
+
if (!project) return null;
|
|
200
|
+
return (await this.isAllowed('project.read', project)) ? project : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async canReadProject(projectId: string | undefined): Promise<boolean> {
|
|
204
|
+
if (!this.enforcing) return true;
|
|
205
|
+
return (await this.readableProject(projectId)) !== null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
|
|
210
|
+
* that belong to another tenant) are reported as not found so their existence is not revealed.
|
|
211
|
+
*/
|
|
212
|
+
private async requireProjectAccess(
|
|
213
|
+
action: AuthorizationAction,
|
|
214
|
+
projectId: string | undefined,
|
|
215
|
+
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
if (!this.enforcing) return;
|
|
218
|
+
const project = await this.readableProject(projectId);
|
|
219
|
+
if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
|
|
220
|
+
if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
|
|
221
|
+
throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
|
|
226
|
+
if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
|
|
230
|
+
private async filterReadable<T>(
|
|
231
|
+
items: T[],
|
|
232
|
+
projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
|
|
233
|
+
): Promise<T[]> {
|
|
234
|
+
if (!this.enforcing) return items;
|
|
235
|
+
const readable = new Map<string, boolean>();
|
|
236
|
+
const result: T[] = [];
|
|
237
|
+
for (const item of items) {
|
|
238
|
+
const projectId = await projectIdOf(item);
|
|
239
|
+
if (!projectId) continue;
|
|
240
|
+
if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
|
|
241
|
+
if (readable.get(projectId)) result.push(item);
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
|
|
247
|
+
return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
|
|
251
|
+
if (!commentId) return undefined;
|
|
252
|
+
const comment = await this.store.getComment(commentId);
|
|
253
|
+
return this.projectIdOfTask(comment?.taskId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
|
|
257
|
+
return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Strips fields only the engine may set (tenancy) from updates made through a view. */
|
|
261
|
+
private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
|
|
262
|
+
if (!this.actor || !('tenantId' in updates)) return updates;
|
|
263
|
+
const { tenantId: _ignored, ...rest } = updates;
|
|
264
|
+
return rest as T;
|
|
265
|
+
}
|
|
266
|
+
|
|
143
267
|
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
144
268
|
if (data.workflows) {
|
|
145
269
|
for (const wf of data.workflows) {
|
|
@@ -180,15 +304,18 @@ export class CriticalPathEngine {
|
|
|
180
304
|
|
|
181
305
|
// --- Workflows ---
|
|
182
306
|
async getWorkflows(): Promise<Workflow[]> {
|
|
183
|
-
|
|
307
|
+
const workflows = await this.store.getWorkflows();
|
|
308
|
+
return workflows.filter((w) => this.inActorTenant(w));
|
|
184
309
|
}
|
|
185
310
|
|
|
186
311
|
async getWorkflow(id: string): Promise<Workflow | null> {
|
|
187
|
-
|
|
312
|
+
const workflow = await this.store.getWorkflow(id);
|
|
313
|
+
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
188
314
|
}
|
|
189
315
|
|
|
190
316
|
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
191
|
-
|
|
317
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
318
|
+
const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
|
|
192
319
|
const now = new Date().toISOString();
|
|
193
320
|
|
|
194
321
|
const event: WorkflowCreatedEvent = {
|
|
@@ -206,13 +333,14 @@ export class CriticalPathEngine {
|
|
|
206
333
|
action: 'workflow.created',
|
|
207
334
|
details: { name: created.name }
|
|
208
335
|
});
|
|
209
|
-
this.dispatchWebhook('workflow.created', { workflow: created });
|
|
210
336
|
return created;
|
|
211
337
|
}
|
|
212
338
|
|
|
213
339
|
async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
|
|
214
|
-
const existing = await this.
|
|
340
|
+
const existing = await this.getWorkflow(id);
|
|
215
341
|
if (!existing) return null;
|
|
342
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
343
|
+
updates = this.withoutTenant(updates);
|
|
216
344
|
|
|
217
345
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
218
346
|
if (updated) {
|
|
@@ -232,14 +360,14 @@ export class CriticalPathEngine {
|
|
|
232
360
|
action: 'workflow.updated',
|
|
233
361
|
details: { name: updated.name }
|
|
234
362
|
});
|
|
235
|
-
this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
|
|
236
363
|
}
|
|
237
364
|
return updated;
|
|
238
365
|
}
|
|
239
366
|
|
|
240
367
|
async deleteWorkflow(id: string): Promise<boolean> {
|
|
241
|
-
const existing = await this.
|
|
368
|
+
const existing = await this.getWorkflow(id);
|
|
242
369
|
if (!existing) return false;
|
|
370
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
243
371
|
|
|
244
372
|
const deleted = await this.store.deleteWorkflow(id);
|
|
245
373
|
if (deleted) {
|
|
@@ -250,7 +378,7 @@ export class CriticalPathEngine {
|
|
|
250
378
|
aggregateId: id,
|
|
251
379
|
aggregateType: 'Workflow',
|
|
252
380
|
occurredAt: now,
|
|
253
|
-
payload: { workflowId: id, name: existing.name }
|
|
381
|
+
payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
|
|
254
382
|
};
|
|
255
383
|
await this.events.publish(event);
|
|
256
384
|
|
|
@@ -259,7 +387,6 @@ export class CriticalPathEngine {
|
|
|
259
387
|
action: 'workflow.deleted',
|
|
260
388
|
details: { name: existing.name }
|
|
261
389
|
});
|
|
262
|
-
this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
|
|
263
390
|
}
|
|
264
391
|
return deleted;
|
|
265
392
|
}
|
|
@@ -272,7 +399,8 @@ export class CriticalPathEngine {
|
|
|
272
399
|
if (wf) return wf;
|
|
273
400
|
}
|
|
274
401
|
|
|
275
|
-
|
|
402
|
+
// Only fall back to workflows from the project's own tenant.
|
|
403
|
+
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
|
|
276
404
|
const defaultWf = workflows.find((w) => w.isDefault);
|
|
277
405
|
if (defaultWf) return defaultWf;
|
|
278
406
|
if (workflows.length > 0) return workflows[0];
|
|
@@ -281,21 +409,21 @@ export class CriticalPathEngine {
|
|
|
281
409
|
}
|
|
282
410
|
|
|
283
411
|
async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
|
|
284
|
-
const task = await this.
|
|
412
|
+
const task = await this.getTask(taskId);
|
|
285
413
|
if (!task) return [];
|
|
286
414
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
287
415
|
return getAllowedTransitions(workflow || undefined, task.status);
|
|
288
416
|
}
|
|
289
417
|
|
|
290
418
|
async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
|
|
291
|
-
const task = await this.
|
|
419
|
+
const task = await this.getTask(taskId);
|
|
292
420
|
if (!task) return [];
|
|
293
421
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
294
422
|
return getAllowedNextStatuses(workflow || undefined, task.status);
|
|
295
423
|
}
|
|
296
424
|
|
|
297
425
|
async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
|
|
298
|
-
const task = await this.
|
|
426
|
+
const task = await this.getTask(taskId);
|
|
299
427
|
if (!task) return [];
|
|
300
428
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
301
429
|
return getAllowedPreviousStatuses(workflow || undefined, task.status);
|
|
@@ -303,15 +431,24 @@ export class CriticalPathEngine {
|
|
|
303
431
|
|
|
304
432
|
// --- Projects ---
|
|
305
433
|
async getProjects(): Promise<Project[]> {
|
|
306
|
-
|
|
434
|
+
const projects = await this.store.getProjects();
|
|
435
|
+
return this.filterReadable(projects, (p) => p.id);
|
|
307
436
|
}
|
|
308
437
|
|
|
309
438
|
async getProject(id: string): Promise<Project | null> {
|
|
310
|
-
return this.store.getProject(id);
|
|
439
|
+
if (!this.enforcing) return this.store.getProject(id);
|
|
440
|
+
return this.readableProject(id);
|
|
311
441
|
}
|
|
312
442
|
|
|
313
443
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
314
|
-
|
|
444
|
+
await this.requireWorkspaceAccess('project.create');
|
|
445
|
+
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
446
|
+
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
447
|
+
// The creator administers the project they create.
|
|
448
|
+
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
|
|
449
|
+
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
450
|
+
}
|
|
451
|
+
const created = await this.store.createProject(input);
|
|
315
452
|
const now = new Date().toISOString();
|
|
316
453
|
|
|
317
454
|
const event: ProjectCreatedEvent = {
|
|
@@ -330,13 +467,15 @@ export class CriticalPathEngine {
|
|
|
330
467
|
action: 'project.created',
|
|
331
468
|
details: { name: created.name, key: created.key }
|
|
332
469
|
});
|
|
333
|
-
this.dispatchWebhook('project.created', { project: created });
|
|
334
470
|
return created;
|
|
335
471
|
}
|
|
336
472
|
|
|
337
473
|
async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
|
|
338
|
-
const existing = await this.
|
|
474
|
+
const existing = await this.getProject(id);
|
|
339
475
|
if (!existing) return null;
|
|
476
|
+
await this.requireProjectAccess('project.update', id);
|
|
477
|
+
if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
|
|
478
|
+
updates = this.withoutTenant(updates);
|
|
340
479
|
|
|
341
480
|
const updated = await this.store.updateProject(id, updates);
|
|
342
481
|
if (updated) {
|
|
@@ -357,7 +496,6 @@ export class CriticalPathEngine {
|
|
|
357
496
|
action: 'project.updated',
|
|
358
497
|
details: { name: updated.name }
|
|
359
498
|
});
|
|
360
|
-
this.dispatchWebhook('project.updated', { project: updated });
|
|
361
499
|
}
|
|
362
500
|
return updated;
|
|
363
501
|
}
|
|
@@ -367,13 +505,16 @@ export class CriticalPathEngine {
|
|
|
367
505
|
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
368
506
|
*/
|
|
369
507
|
async deleteProject(id: string): Promise<boolean> {
|
|
370
|
-
const existing = await this.
|
|
508
|
+
const existing = await this.getProject(id);
|
|
371
509
|
if (!existing) return false;
|
|
510
|
+
await this.requireProjectAccess('project.delete', id);
|
|
511
|
+
// Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
|
|
512
|
+
const cascade = this.elevated();
|
|
372
513
|
|
|
373
514
|
const tasks = await this.store.getTasks(id);
|
|
374
515
|
const deletedTaskIds: string[] = [];
|
|
375
516
|
for (const task of tasks) {
|
|
376
|
-
if (await
|
|
517
|
+
if (await cascade.deleteTask(task.id)) {
|
|
377
518
|
deletedTaskIds.push(task.id);
|
|
378
519
|
}
|
|
379
520
|
}
|
|
@@ -387,7 +528,7 @@ export class CriticalPathEngine {
|
|
|
387
528
|
aggregateId: id,
|
|
388
529
|
aggregateType: 'Project',
|
|
389
530
|
occurredAt: now,
|
|
390
|
-
payload: { projectId: id, name: existing.name, deletedTaskIds }
|
|
531
|
+
payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
|
|
391
532
|
};
|
|
392
533
|
await this.events.publish(event);
|
|
393
534
|
|
|
@@ -397,21 +538,25 @@ export class CriticalPathEngine {
|
|
|
397
538
|
action: 'project.deleted',
|
|
398
539
|
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
399
540
|
});
|
|
400
|
-
this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
|
|
401
541
|
}
|
|
402
542
|
return deleted;
|
|
403
543
|
}
|
|
404
544
|
|
|
405
545
|
// --- Tasks ---
|
|
406
546
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
407
|
-
|
|
547
|
+
if (projectId && !(await this.canReadProject(projectId))) return [];
|
|
548
|
+
const tasks = await this.store.getTasks(projectId);
|
|
549
|
+
return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
|
|
408
550
|
}
|
|
409
551
|
|
|
410
552
|
async getTask(id: string): Promise<Task | null> {
|
|
411
|
-
|
|
553
|
+
const task = await this.store.getTask(id);
|
|
554
|
+
if (!task) return null;
|
|
555
|
+
return (await this.canReadProject(task.projectId)) ? task : null;
|
|
412
556
|
}
|
|
413
557
|
|
|
414
558
|
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
559
|
+
await this.requireProjectAccess('task.create', taskInput.projectId);
|
|
415
560
|
if (this.actor && !taskInput.reporterId) {
|
|
416
561
|
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
417
562
|
}
|
|
@@ -505,7 +650,6 @@ export class CriticalPathEngine {
|
|
|
505
650
|
details: { title: created.title, status: created.status }
|
|
506
651
|
});
|
|
507
652
|
|
|
508
|
-
this.dispatchWebhook('task.created', { task: created });
|
|
509
653
|
return created;
|
|
510
654
|
}
|
|
511
655
|
|
|
@@ -519,8 +663,9 @@ export class CriticalPathEngine {
|
|
|
519
663
|
actor?: { userId: string; username?: string; actorType?: string };
|
|
520
664
|
}
|
|
521
665
|
): Promise<Task | null> {
|
|
522
|
-
const existing = await this.
|
|
666
|
+
const existing = await this.getTask(id);
|
|
523
667
|
if (!existing) return null;
|
|
668
|
+
await this.requireProjectAccess('task.update', existing.projectId);
|
|
524
669
|
|
|
525
670
|
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
526
671
|
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
@@ -787,10 +932,6 @@ export class CriticalPathEngine {
|
|
|
787
932
|
action: 'task.blocked',
|
|
788
933
|
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
789
934
|
});
|
|
790
|
-
this.dispatchWebhook('task.blocked', {
|
|
791
|
-
task: updated,
|
|
792
|
-
reason: blockedReason
|
|
793
|
-
});
|
|
794
935
|
} else if (wasBlocked && !isNowBlocked) {
|
|
795
936
|
const unblockedEvent: TaskUnblockedEvent = {
|
|
796
937
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -813,9 +954,6 @@ export class CriticalPathEngine {
|
|
|
813
954
|
action: 'task.unblocked',
|
|
814
955
|
details: { ...(actorName ? { actorName } : {}) }
|
|
815
956
|
});
|
|
816
|
-
this.dispatchWebhook('task.unblocked', {
|
|
817
|
-
task: updated
|
|
818
|
-
});
|
|
819
957
|
}
|
|
820
958
|
|
|
821
959
|
await this.store.logActivity({
|
|
@@ -830,10 +968,6 @@ export class CriticalPathEngine {
|
|
|
830
968
|
}
|
|
831
969
|
});
|
|
832
970
|
|
|
833
|
-
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
|
834
|
-
task: updated,
|
|
835
|
-
previous: existing
|
|
836
|
-
});
|
|
837
971
|
|
|
838
972
|
return updated;
|
|
839
973
|
}
|
|
@@ -904,10 +1038,6 @@ export class CriticalPathEngine {
|
|
|
904
1038
|
details: { unblockedByTaskId: completedTask.id }
|
|
905
1039
|
});
|
|
906
1040
|
|
|
907
|
-
this.dispatchWebhook('task.unblocked', {
|
|
908
|
-
task: updatedDownstream,
|
|
909
|
-
upstreamTaskId: completedTask.id
|
|
910
|
-
});
|
|
911
1041
|
}
|
|
912
1042
|
}
|
|
913
1043
|
} catch (err) {
|
|
@@ -916,8 +1046,9 @@ export class CriticalPathEngine {
|
|
|
916
1046
|
}
|
|
917
1047
|
|
|
918
1048
|
async deleteTask(id: string): Promise<boolean> {
|
|
919
|
-
const existing = await this.
|
|
1049
|
+
const existing = await this.getTask(id);
|
|
920
1050
|
if (!existing) return false;
|
|
1051
|
+
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
921
1052
|
|
|
922
1053
|
await this.plugins.runBeforeTaskDelete(id);
|
|
923
1054
|
const deleted = await this.store.deleteTask(id);
|
|
@@ -947,13 +1078,18 @@ export class CriticalPathEngine {
|
|
|
947
1078
|
action: 'task.deleted',
|
|
948
1079
|
details: { title: existing.title }
|
|
949
1080
|
});
|
|
950
|
-
this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
|
|
951
1081
|
}
|
|
952
1082
|
return deleted;
|
|
953
1083
|
}
|
|
954
1084
|
|
|
955
1085
|
// --- Task Dependencies & Graph ---
|
|
956
1086
|
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
1087
|
+
if (this.enforcing) {
|
|
1088
|
+
const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
|
|
1089
|
+
if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
|
|
1090
|
+
if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
|
|
1091
|
+
await this.requireProjectAccess('task.update', task.projectId);
|
|
1092
|
+
}
|
|
957
1093
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
958
1094
|
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
959
1095
|
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
@@ -1009,6 +1145,9 @@ export class CriticalPathEngine {
|
|
|
1009
1145
|
}
|
|
1010
1146
|
|
|
1011
1147
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
1148
|
+
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
1149
|
+
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
1150
|
+
}
|
|
1012
1151
|
const dependencies = await this.store.getDependencies(taskId);
|
|
1013
1152
|
const upstreamTaskIds = new Set<string>();
|
|
1014
1153
|
const downstreamTaskIds = new Set<string>();
|
|
@@ -1022,11 +1161,11 @@ export class CriticalPathEngine {
|
|
|
1022
1161
|
}
|
|
1023
1162
|
|
|
1024
1163
|
const upstreamTasks = (
|
|
1025
|
-
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.
|
|
1164
|
+
await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
|
|
1026
1165
|
).filter((t): t is Task => t !== null);
|
|
1027
1166
|
|
|
1028
1167
|
const downstreamTasks = (
|
|
1029
|
-
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
1168
|
+
await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
|
|
1030
1169
|
).filter((t): t is Task => t !== null);
|
|
1031
1170
|
|
|
1032
1171
|
return {
|
|
@@ -1064,7 +1203,11 @@ export class CriticalPathEngine {
|
|
|
1064
1203
|
throw new ValidationError('Logged hours must be a positive number.');
|
|
1065
1204
|
}
|
|
1066
1205
|
|
|
1067
|
-
const task = await this.
|
|
1206
|
+
const task = await this.getTask(entry.taskId);
|
|
1207
|
+
if (this.enforcing) {
|
|
1208
|
+
if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
|
|
1209
|
+
await this.requireProjectAccess('time.log', task.projectId);
|
|
1210
|
+
}
|
|
1068
1211
|
if (task) {
|
|
1069
1212
|
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
1070
1213
|
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
@@ -1100,16 +1243,34 @@ export class CriticalPathEngine {
|
|
|
1100
1243
|
|
|
1101
1244
|
// --- Comments ---
|
|
1102
1245
|
async getComments(taskId: string): Promise<Comment[]> {
|
|
1246
|
+
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1103
1247
|
return this.store.getComments(taskId);
|
|
1104
1248
|
}
|
|
1105
1249
|
|
|
1106
1250
|
async getComment(id: string): Promise<Comment | null> {
|
|
1107
|
-
|
|
1251
|
+
const comment = await this.store.getComment(id);
|
|
1252
|
+
if (!comment) return null;
|
|
1253
|
+
return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
|
|
1257
|
+
const activities = await this.store.getActivities(filter);
|
|
1258
|
+
return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
|
|
1262
|
+
if (this.enforcing && !(await this.getTask(taskId))) return [];
|
|
1263
|
+
return this.store.getTimeEntries(taskId);
|
|
1108
1264
|
}
|
|
1109
1265
|
|
|
1110
1266
|
async addComment(
|
|
1111
1267
|
input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
|
|
1112
1268
|
): Promise<Comment> {
|
|
1269
|
+
if (this.enforcing) {
|
|
1270
|
+
const task = await this.getTask(input.taskId);
|
|
1271
|
+
if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
|
|
1272
|
+
await this.requireProjectAccess('comment.create', task.projectId);
|
|
1273
|
+
}
|
|
1113
1274
|
const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1114
1275
|
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
1115
1276
|
: { ...input, authorId: input.authorId ?? 'system' };
|
|
@@ -1127,13 +1288,16 @@ export class CriticalPathEngine {
|
|
|
1127
1288
|
};
|
|
1128
1289
|
await this.events.publish(event);
|
|
1129
1290
|
|
|
1130
|
-
this.dispatchWebhook('comment.created', { comment: created });
|
|
1131
1291
|
return created;
|
|
1132
1292
|
}
|
|
1133
1293
|
|
|
1134
1294
|
async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
|
|
1135
|
-
const existing = await this.
|
|
1295
|
+
const existing = await this.getComment(id);
|
|
1136
1296
|
if (!existing) return null;
|
|
1297
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1298
|
+
type: 'comment',
|
|
1299
|
+
ownerId: existing.authorId
|
|
1300
|
+
});
|
|
1137
1301
|
|
|
1138
1302
|
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
1139
1303
|
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
@@ -1152,13 +1316,16 @@ export class CriticalPathEngine {
|
|
|
1152
1316
|
};
|
|
1153
1317
|
await this.events.publish(event);
|
|
1154
1318
|
|
|
1155
|
-
this.dispatchWebhook('comment.updated', { comment: updated });
|
|
1156
1319
|
return updated;
|
|
1157
1320
|
}
|
|
1158
1321
|
|
|
1159
1322
|
async deleteComment(id: string): Promise<boolean> {
|
|
1160
|
-
const existing = await this.
|
|
1323
|
+
const existing = await this.getComment(id);
|
|
1161
1324
|
if (!existing) return false;
|
|
1325
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1326
|
+
type: 'comment',
|
|
1327
|
+
ownerId: existing.authorId
|
|
1328
|
+
});
|
|
1162
1329
|
|
|
1163
1330
|
const deleted = await this.store.deleteComment(id);
|
|
1164
1331
|
if (deleted) {
|
|
@@ -1173,15 +1340,15 @@ export class CriticalPathEngine {
|
|
|
1173
1340
|
};
|
|
1174
1341
|
await this.events.publish(event);
|
|
1175
1342
|
|
|
1176
|
-
this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
|
|
1177
1343
|
}
|
|
1178
1344
|
return deleted;
|
|
1179
1345
|
}
|
|
1180
1346
|
|
|
1181
1347
|
async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1182
1348
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1183
|
-
const existing = await this.
|
|
1349
|
+
const existing = await this.getComment(commentId);
|
|
1184
1350
|
if (!existing) return null;
|
|
1351
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1185
1352
|
|
|
1186
1353
|
let updated: Comment | null;
|
|
1187
1354
|
if (this.store.addReaction) {
|
|
@@ -1220,14 +1387,14 @@ export class CriticalPathEngine {
|
|
|
1220
1387
|
};
|
|
1221
1388
|
await this.events.publish(event);
|
|
1222
1389
|
|
|
1223
|
-
this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
|
|
1224
1390
|
return updated;
|
|
1225
1391
|
}
|
|
1226
1392
|
|
|
1227
1393
|
async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
|
|
1228
1394
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1229
|
-
const existing = await this.
|
|
1395
|
+
const existing = await this.getComment(commentId);
|
|
1230
1396
|
if (!existing) return null;
|
|
1397
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1231
1398
|
|
|
1232
1399
|
let updated: Comment | null;
|
|
1233
1400
|
if (this.store.removeReaction) {
|
|
@@ -1252,13 +1419,14 @@ export class CriticalPathEngine {
|
|
|
1252
1419
|
};
|
|
1253
1420
|
await this.events.publish(event);
|
|
1254
1421
|
|
|
1255
|
-
this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
|
|
1256
1422
|
return updated;
|
|
1257
1423
|
}
|
|
1258
1424
|
|
|
1259
1425
|
// --- Attachments ---
|
|
1260
1426
|
async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
|
|
1261
|
-
const attachments = await this.store.getAttachments(filter)
|
|
1427
|
+
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
|
|
1428
|
+
this.projectIdOfAttachment(a)
|
|
1429
|
+
);
|
|
1262
1430
|
if (filter?.artifactType) {
|
|
1263
1431
|
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1264
1432
|
}
|
|
@@ -1266,12 +1434,22 @@ export class CriticalPathEngine {
|
|
|
1266
1434
|
}
|
|
1267
1435
|
|
|
1268
1436
|
async getAttachment(id: string): Promise<Attachment | null> {
|
|
1269
|
-
|
|
1437
|
+
const attachment = await this.store.getAttachment(id);
|
|
1438
|
+
if (!attachment) return null;
|
|
1439
|
+
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
|
|
1443
|
+
if (!this.enforcing) return;
|
|
1444
|
+
const projectId = await this.projectIdOfAttachment(link);
|
|
1445
|
+
if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1446
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1270
1447
|
}
|
|
1271
1448
|
|
|
1272
1449
|
async createAttachment(
|
|
1273
1450
|
input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
|
|
1274
1451
|
): Promise<Attachment> {
|
|
1452
|
+
await this.requireAttachmentCreate(input);
|
|
1275
1453
|
const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1276
1454
|
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1277
1455
|
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
@@ -1289,13 +1467,16 @@ export class CriticalPathEngine {
|
|
|
1289
1467
|
};
|
|
1290
1468
|
await this.events.publish(event);
|
|
1291
1469
|
|
|
1292
|
-
this.dispatchWebhook('attachment.created', { attachment: created });
|
|
1293
1470
|
return created;
|
|
1294
1471
|
}
|
|
1295
1472
|
|
|
1296
1473
|
async deleteAttachment(id: string): Promise<boolean> {
|
|
1297
|
-
const existing = await this.
|
|
1474
|
+
const existing = await this.getAttachment(id);
|
|
1298
1475
|
if (!existing) return false;
|
|
1476
|
+
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1477
|
+
type: 'attachment',
|
|
1478
|
+
ownerId: existing.uploaderId
|
|
1479
|
+
});
|
|
1299
1480
|
|
|
1300
1481
|
if (existing.storageKey && this.fileStorage) {
|
|
1301
1482
|
try {
|
|
@@ -1314,11 +1495,15 @@ export class CriticalPathEngine {
|
|
|
1314
1495
|
aggregateId: id,
|
|
1315
1496
|
aggregateType: 'Attachment',
|
|
1316
1497
|
occurredAt: now,
|
|
1317
|
-
payload: {
|
|
1498
|
+
payload: {
|
|
1499
|
+
attachmentId: id,
|
|
1500
|
+
storageKey: existing.storageKey,
|
|
1501
|
+
url: existing.url,
|
|
1502
|
+
projectId: await this.projectIdOfAttachment(existing)
|
|
1503
|
+
}
|
|
1318
1504
|
};
|
|
1319
1505
|
await this.events.publish(event);
|
|
1320
1506
|
|
|
1321
|
-
this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
|
|
1322
1507
|
}
|
|
1323
1508
|
return deleted;
|
|
1324
1509
|
}
|
|
@@ -1337,6 +1522,8 @@ export class CriticalPathEngine {
|
|
|
1337
1522
|
if (!this.fileStorage) {
|
|
1338
1523
|
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1339
1524
|
}
|
|
1525
|
+
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1526
|
+
await this.requireAttachmentCreate(input);
|
|
1340
1527
|
|
|
1341
1528
|
const uploadResult = await this.fileStorage.upload({
|
|
1342
1529
|
filename: input.filename,
|
|
@@ -1394,24 +1581,41 @@ export class CriticalPathEngine {
|
|
|
1394
1581
|
throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
|
|
1395
1582
|
}
|
|
1396
1583
|
|
|
1397
|
-
async getPresignedAttachmentUploadUrl(
|
|
1584
|
+
async getPresignedAttachmentUploadUrl(
|
|
1585
|
+
options: PresignedUrlOptions & { projectId?: string }
|
|
1586
|
+
): Promise<PresignedUploadResult> {
|
|
1398
1587
|
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1399
1588
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1400
1589
|
}
|
|
1401
|
-
|
|
1590
|
+
const { projectId, ...presign } = options;
|
|
1591
|
+
if (this.enforcing) {
|
|
1592
|
+
// Scoped callers may only write under their project's prefix.
|
|
1593
|
+
if (!projectId) throw new ValidationError('projectId is required for presigned uploads.');
|
|
1594
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1595
|
+
const prefix = `projects/${projectId}/`;
|
|
1596
|
+
const key = presign.storageKey.replace(/^\/+/, '');
|
|
1597
|
+
if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
1598
|
+
throw new ValidationError('storageKey must not contain "." or ".." segments.');
|
|
1599
|
+
}
|
|
1600
|
+
presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
|
|
1601
|
+
}
|
|
1602
|
+
return this.fileStorage.getPresignedUploadUrl(presign);
|
|
1402
1603
|
}
|
|
1403
1604
|
|
|
1404
1605
|
// --- Teams ---
|
|
1405
1606
|
async getTeams(): Promise<Team[]> {
|
|
1406
|
-
|
|
1607
|
+
const teams = await this.store.getTeams();
|
|
1608
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1407
1609
|
}
|
|
1408
1610
|
|
|
1409
1611
|
async getTeam(id: string): Promise<Team | null> {
|
|
1410
|
-
|
|
1612
|
+
const team = await this.store.getTeam(id);
|
|
1613
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1411
1614
|
}
|
|
1412
1615
|
|
|
1413
1616
|
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
1414
|
-
|
|
1617
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1618
|
+
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1415
1619
|
const now = new Date().toISOString();
|
|
1416
1620
|
|
|
1417
1621
|
const event: TeamCreatedEvent = {
|
|
@@ -1424,28 +1628,35 @@ export class CriticalPathEngine {
|
|
|
1424
1628
|
};
|
|
1425
1629
|
await this.events.publish(event);
|
|
1426
1630
|
|
|
1427
|
-
this.dispatchWebhook('team.created', { team: created });
|
|
1428
1631
|
return created;
|
|
1429
1632
|
}
|
|
1430
1633
|
|
|
1431
1634
|
async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
|
|
1432
|
-
|
|
1635
|
+
if (!(await this.getTeam(id))) return null;
|
|
1636
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1637
|
+
return this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1433
1638
|
}
|
|
1434
1639
|
|
|
1435
1640
|
async deleteTeam(id: string): Promise<boolean> {
|
|
1641
|
+
if (!(await this.getTeam(id))) return false;
|
|
1642
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1436
1643
|
return this.store.deleteTeam(id);
|
|
1437
1644
|
}
|
|
1438
1645
|
|
|
1439
1646
|
// --- Containers ---
|
|
1440
1647
|
async getContainers(projectId: string): Promise<TaskContainer[]> {
|
|
1648
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1441
1649
|
return this.store.getContainers(projectId);
|
|
1442
1650
|
}
|
|
1443
1651
|
|
|
1444
1652
|
async getContainer(id: string): Promise<TaskContainer | null> {
|
|
1445
|
-
|
|
1653
|
+
const container = await this.store.getContainer(id);
|
|
1654
|
+
if (!container) return null;
|
|
1655
|
+
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1446
1656
|
}
|
|
1447
1657
|
|
|
1448
1658
|
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
1659
|
+
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1449
1660
|
const created = await this.store.createContainer(container);
|
|
1450
1661
|
const now = new Date().toISOString();
|
|
1451
1662
|
|
|
@@ -1459,28 +1670,37 @@ export class CriticalPathEngine {
|
|
|
1459
1670
|
};
|
|
1460
1671
|
await this.events.publish(event);
|
|
1461
1672
|
|
|
1462
|
-
this.dispatchWebhook('container.created', { container: created });
|
|
1463
1673
|
return created;
|
|
1464
1674
|
}
|
|
1465
1675
|
|
|
1466
1676
|
async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
|
|
1677
|
+
const existing = await this.getContainer(id);
|
|
1678
|
+
if (!existing) return null;
|
|
1679
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1467
1680
|
return this.store.updateContainer(id, updates);
|
|
1468
1681
|
}
|
|
1469
1682
|
|
|
1470
1683
|
async deleteContainer(id: string): Promise<boolean> {
|
|
1684
|
+
const existing = await this.getContainer(id);
|
|
1685
|
+
if (!existing) return false;
|
|
1686
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1471
1687
|
return this.store.deleteContainer(id);
|
|
1472
1688
|
}
|
|
1473
1689
|
|
|
1474
1690
|
// --- Deliverables ---
|
|
1475
1691
|
async getDeliverables(projectId: string): Promise<Deliverable[]> {
|
|
1692
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1476
1693
|
return this.store.getDeliverables(projectId);
|
|
1477
1694
|
}
|
|
1478
1695
|
|
|
1479
1696
|
async getDeliverable(id: string): Promise<Deliverable | null> {
|
|
1480
|
-
|
|
1697
|
+
const deliverable = await this.store.getDeliverable(id);
|
|
1698
|
+
if (!deliverable) return null;
|
|
1699
|
+
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1481
1700
|
}
|
|
1482
1701
|
|
|
1483
1702
|
async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
|
|
1703
|
+
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1484
1704
|
const project = await this.store.getProject(input.projectId);
|
|
1485
1705
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1486
1706
|
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
@@ -1510,13 +1730,13 @@ export class CriticalPathEngine {
|
|
|
1510
1730
|
action: 'deliverable.created',
|
|
1511
1731
|
details: { title: created.title, format: created.format }
|
|
1512
1732
|
});
|
|
1513
|
-
this.dispatchWebhook('deliverable.created', { deliverable: created });
|
|
1514
1733
|
return created;
|
|
1515
1734
|
}
|
|
1516
1735
|
|
|
1517
1736
|
async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
|
|
1518
|
-
const existing = await this.
|
|
1737
|
+
const existing = await this.getDeliverable(id);
|
|
1519
1738
|
if (!existing) return null;
|
|
1739
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1520
1740
|
|
|
1521
1741
|
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1522
1742
|
updates.deliveredAt = new Date().toISOString();
|
|
@@ -1540,11 +1760,6 @@ export class CriticalPathEngine {
|
|
|
1540
1760
|
}
|
|
1541
1761
|
};
|
|
1542
1762
|
await this.events.publish(statusEvent);
|
|
1543
|
-
this.dispatchWebhook('deliverable.status_changed', {
|
|
1544
|
-
deliverable: updated,
|
|
1545
|
-
previousStatus: existing.status,
|
|
1546
|
-
newStatus: updated.status
|
|
1547
|
-
});
|
|
1548
1763
|
}
|
|
1549
1764
|
|
|
1550
1765
|
const updateEvent: DeliverableUpdatedEvent = {
|
|
@@ -1563,14 +1778,14 @@ export class CriticalPathEngine {
|
|
|
1563
1778
|
action: 'deliverable.updated',
|
|
1564
1779
|
details: { title: updated.title, status: updated.status }
|
|
1565
1780
|
});
|
|
1566
|
-
this.dispatchWebhook('deliverable.updated', { deliverable: updated });
|
|
1567
1781
|
}
|
|
1568
1782
|
return updated;
|
|
1569
1783
|
}
|
|
1570
1784
|
|
|
1571
1785
|
async deleteDeliverable(id: string): Promise<boolean> {
|
|
1572
|
-
const existing = await this.
|
|
1786
|
+
const existing = await this.getDeliverable(id);
|
|
1573
1787
|
if (!existing) return false;
|
|
1788
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1574
1789
|
|
|
1575
1790
|
const deleted = await this.store.deleteDeliverable(id);
|
|
1576
1791
|
if (deleted) {
|
|
@@ -1591,13 +1806,12 @@ export class CriticalPathEngine {
|
|
|
1591
1806
|
action: 'deliverable.deleted',
|
|
1592
1807
|
details: { title: existing.title }
|
|
1593
1808
|
});
|
|
1594
|
-
this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
|
|
1595
1809
|
}
|
|
1596
1810
|
return deleted;
|
|
1597
1811
|
}
|
|
1598
1812
|
|
|
1599
1813
|
async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
|
|
1600
|
-
const deliverable = await this.
|
|
1814
|
+
const deliverable = await this.getDeliverable(deliverableId);
|
|
1601
1815
|
if (!deliverable) return null;
|
|
1602
1816
|
|
|
1603
1817
|
const project = await this.store.getProject(deliverable.projectId);
|
|
@@ -1645,14 +1859,18 @@ export class CriticalPathEngine {
|
|
|
1645
1859
|
|
|
1646
1860
|
// --- Iterations ---
|
|
1647
1861
|
async getIterations(projectId: string): Promise<Iteration[]> {
|
|
1862
|
+
if (!(await this.canReadProject(projectId))) return [];
|
|
1648
1863
|
return this.store.getIterations(projectId);
|
|
1649
1864
|
}
|
|
1650
1865
|
|
|
1651
1866
|
async getIteration(id: string): Promise<Iteration | null> {
|
|
1652
|
-
|
|
1867
|
+
const iteration = await this.store.getIteration(id);
|
|
1868
|
+
if (!iteration) return null;
|
|
1869
|
+
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
1653
1870
|
}
|
|
1654
1871
|
|
|
1655
1872
|
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
1873
|
+
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
1656
1874
|
const created = await this.store.createIteration(iteration);
|
|
1657
1875
|
if (created.status === 'active') {
|
|
1658
1876
|
const now = new Date().toISOString();
|
|
@@ -1665,14 +1883,14 @@ export class CriticalPathEngine {
|
|
|
1665
1883
|
payload: { iteration: created }
|
|
1666
1884
|
};
|
|
1667
1885
|
await this.events.publish(event);
|
|
1668
|
-
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
1669
1886
|
}
|
|
1670
1887
|
return created;
|
|
1671
1888
|
}
|
|
1672
1889
|
|
|
1673
1890
|
async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
|
|
1674
|
-
const existing = await this.
|
|
1891
|
+
const existing = await this.getIteration(id);
|
|
1675
1892
|
if (!existing) return null;
|
|
1893
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1676
1894
|
|
|
1677
1895
|
const updated = await this.store.updateIteration(id, updates);
|
|
1678
1896
|
if (updated) {
|
|
@@ -1687,7 +1905,6 @@ export class CriticalPathEngine {
|
|
|
1687
1905
|
payload: { iteration: updated }
|
|
1688
1906
|
};
|
|
1689
1907
|
await this.events.publish(event);
|
|
1690
|
-
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
1691
1908
|
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
1692
1909
|
const event: IterationCompletedEvent = {
|
|
1693
1910
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1698,13 +1915,15 @@ export class CriticalPathEngine {
|
|
|
1698
1915
|
payload: { iteration: updated }
|
|
1699
1916
|
};
|
|
1700
1917
|
await this.events.publish(event);
|
|
1701
|
-
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
1702
1918
|
}
|
|
1703
1919
|
}
|
|
1704
1920
|
return updated;
|
|
1705
1921
|
}
|
|
1706
1922
|
|
|
1707
1923
|
async deleteIteration(id: string): Promise<boolean> {
|
|
1924
|
+
const existing = await this.getIteration(id);
|
|
1925
|
+
if (!existing) return false;
|
|
1926
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1708
1927
|
return this.store.deleteIteration(id);
|
|
1709
1928
|
}
|
|
1710
1929
|
|
|
@@ -1713,6 +1932,7 @@ export class CriticalPathEngine {
|
|
|
1713
1932
|
projectId: string,
|
|
1714
1933
|
options: CPMOptions = {}
|
|
1715
1934
|
): Promise<CriticalPathAnalysis> {
|
|
1935
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1716
1936
|
const project = await this.store.getProject(projectId);
|
|
1717
1937
|
const tasks = await this.store.getTasks(projectId);
|
|
1718
1938
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1735,6 +1955,7 @@ export class CriticalPathEngine {
|
|
|
1735
1955
|
projectId: string,
|
|
1736
1956
|
options: TimelineLadderOptions = {}
|
|
1737
1957
|
): Promise<TimelineLadder> {
|
|
1958
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1738
1959
|
const project = await this.store.getProject(projectId);
|
|
1739
1960
|
if (!project) {
|
|
1740
1961
|
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
@@ -1791,7 +2012,7 @@ export class CriticalPathEngine {
|
|
|
1791
2012
|
}
|
|
1792
2013
|
|
|
1793
2014
|
async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
|
|
1794
|
-
const task = await this.
|
|
2015
|
+
const task = await this.getTask(taskId);
|
|
1795
2016
|
if (!task) return null;
|
|
1796
2017
|
|
|
1797
2018
|
const project = await this.store.getProject(task.projectId);
|
|
@@ -1815,7 +2036,7 @@ export class CriticalPathEngine {
|
|
|
1815
2036
|
}
|
|
1816
2037
|
|
|
1817
2038
|
async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
|
|
1818
|
-
const task = await this.
|
|
2039
|
+
const task = await this.getTask(taskId);
|
|
1819
2040
|
if (!task) return null;
|
|
1820
2041
|
|
|
1821
2042
|
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
@@ -1834,7 +2055,7 @@ export class CriticalPathEngine {
|
|
|
1834
2055
|
}
|
|
1835
2056
|
|
|
1836
2057
|
async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
|
|
1837
|
-
const task = await this.
|
|
2058
|
+
const task = await this.getTask(taskId);
|
|
1838
2059
|
if (!task) return null;
|
|
1839
2060
|
|
|
1840
2061
|
const [activities, project, workflow] = await Promise.all([
|
|
@@ -1855,8 +2076,9 @@ export class CriticalPathEngine {
|
|
|
1855
2076
|
projectId?: string,
|
|
1856
2077
|
options: WorkloadDistributionOptions = {}
|
|
1857
2078
|
): Promise<WorkloadDistribution> {
|
|
1858
|
-
|
|
1859
|
-
const
|
|
2079
|
+
if (projectId) await this.requireProjectAccess('project.read', projectId);
|
|
2080
|
+
const tasks = await this.getTasks(projectId);
|
|
2081
|
+
const teams = await this.getTeams();
|
|
1860
2082
|
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1861
2083
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1862
2084
|
|
|
@@ -1877,19 +2099,108 @@ export class CriticalPathEngine {
|
|
|
1877
2099
|
);
|
|
1878
2100
|
}
|
|
1879
2101
|
|
|
1880
|
-
|
|
2102
|
+
// --- Webhooks ---
|
|
2103
|
+
|
|
2104
|
+
/** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
|
|
2105
|
+
private async listAllWebhooks(): Promise<Webhook[]> {
|
|
2106
|
+
const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
|
|
2107
|
+
...w,
|
|
2108
|
+
id: `config_${i}`,
|
|
2109
|
+
createdAt: new Date(0).toISOString()
|
|
2110
|
+
}));
|
|
2111
|
+
return [...fromConfig, ...(await this.store.getWebhooks())];
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
|
|
2115
|
+
return { ...webhook, hasSecret: !!secret };
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
|
|
2119
|
+
async getWebhooks(): Promise<PublicWebhook[]> {
|
|
2120
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1881
2121
|
const webhooks = await this.store.getWebhooks();
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
2122
|
+
return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async getWebhook(id: string): Promise<PublicWebhook | null> {
|
|
2126
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2127
|
+
const webhook = await this.store.getWebhook(id);
|
|
2128
|
+
return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Registers a webhook. The signing secret is returned only here; generate one by omitting
|
|
2133
|
+
* `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
|
|
2134
|
+
*/
|
|
2135
|
+
async createWebhook(input: {
|
|
2136
|
+
name: string;
|
|
2137
|
+
url: string;
|
|
2138
|
+
events: Webhook['events'];
|
|
2139
|
+
secret?: string;
|
|
2140
|
+
active?: boolean;
|
|
2141
|
+
}): Promise<{ webhook: PublicWebhook; secret: string }> {
|
|
2142
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2143
|
+
this.validateWebhookUrl(input.url);
|
|
2144
|
+
const secret = input.secret ?? generateWebhookSecret();
|
|
2145
|
+
const created = await this.store.addWebhook({
|
|
2146
|
+
name: input.name,
|
|
2147
|
+
url: input.url,
|
|
2148
|
+
events: input.events,
|
|
2149
|
+
secret,
|
|
2150
|
+
active: input.active ?? true,
|
|
2151
|
+
tenantId: this.actor?.tenantId
|
|
2152
|
+
});
|
|
2153
|
+
this.webhooks.invalidate();
|
|
2154
|
+
return { webhook: this.toPublicWebhook(created), secret };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
async updateWebhook(
|
|
2158
|
+
id: string,
|
|
2159
|
+
updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
|
|
2160
|
+
): Promise<PublicWebhook | null> {
|
|
2161
|
+
if (!(await this.getWebhook(id))) return null;
|
|
2162
|
+
if (updates.url) this.validateWebhookUrl(updates.url);
|
|
2163
|
+
const { name, url, events, active, secret } = updates;
|
|
2164
|
+
const updated = await this.store.updateWebhook(
|
|
2165
|
+
id,
|
|
2166
|
+
Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
|
|
2167
|
+
);
|
|
2168
|
+
this.webhooks.invalidate();
|
|
2169
|
+
return updated ? this.toPublicWebhook(updated) : null;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
2173
|
+
if (!(await this.getWebhook(id))) return false;
|
|
2174
|
+
const deleted = await this.store.deleteWebhook(id);
|
|
2175
|
+
this.webhooks.invalidate();
|
|
2176
|
+
return deleted;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
private validateWebhookUrl(url: string): void {
|
|
2180
|
+
try {
|
|
2181
|
+
assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
|
|
2182
|
+
} catch (err) {
|
|
2183
|
+
throw new ValidationError((err as Error).message);
|
|
1892
2184
|
}
|
|
1893
2185
|
}
|
|
1894
|
-
}
|
|
1895
2186
|
|
|
2187
|
+
/** Finds the tenant an event belongs to, from the entity in its payload or its project. */
|
|
2188
|
+
private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
|
|
2189
|
+
const p = event.payload as Record<string, any>;
|
|
2190
|
+
const owner = p.project ?? p.workflow ?? p.team;
|
|
2191
|
+
if (owner) return owner.tenantId ?? undefined;
|
|
2192
|
+
// Deleted projects and workflows carry their tenant in the payload.
|
|
2193
|
+
if ('tenantId' in p) return p.tenantId ?? undefined;
|
|
2194
|
+
|
|
2195
|
+
const projectId: string | undefined =
|
|
2196
|
+
p.projectId ??
|
|
2197
|
+
p.task?.projectId ??
|
|
2198
|
+
p.deliverable?.projectId ??
|
|
2199
|
+
p.iteration?.projectId ??
|
|
2200
|
+
p.container?.projectId ??
|
|
2201
|
+
p.attachment?.projectId ??
|
|
2202
|
+
(await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
|
|
2203
|
+
if (!projectId) return undefined;
|
|
2204
|
+
return (await this.store.getProject(projectId))?.tenantId ?? undefined;
|
|
2205
|
+
}
|
|
2206
|
+
}
|