@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/dist/engine/index.js
CHANGED
|
@@ -7,7 +7,9 @@ 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
|
+
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
12
|
+
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
11
13
|
import { calculateCPM } from '../domain/cpm.js';
|
|
12
14
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
13
15
|
import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
|
|
@@ -18,9 +20,13 @@ export class CriticalPathEngine {
|
|
|
18
20
|
fileStorage;
|
|
19
21
|
plugins;
|
|
20
22
|
events;
|
|
23
|
+
/** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
|
|
24
|
+
webhooks;
|
|
21
25
|
ready = Promise.resolve();
|
|
22
26
|
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
23
27
|
actor;
|
|
28
|
+
/** Set on internal views that keep the actor for attribution but skip authorization. */
|
|
29
|
+
authorizationBypassed;
|
|
24
30
|
constructor(config = {}) {
|
|
25
31
|
this.config = config;
|
|
26
32
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
@@ -29,6 +35,11 @@ export class CriticalPathEngine {
|
|
|
29
35
|
this.fileStorage = config.fileStorage;
|
|
30
36
|
this.plugins = new PluginRegistry();
|
|
31
37
|
this.events = new DomainEventBus();
|
|
38
|
+
this.webhooks = new WebhookDispatcher({
|
|
39
|
+
listWebhooks: () => this.listAllWebhooks(),
|
|
40
|
+
resolveTenant: (event) => this.resolveEventTenant(event)
|
|
41
|
+
}, config.webhookDelivery);
|
|
42
|
+
this.events.subscribe('*', (event) => this.webhooks.handle(event));
|
|
32
43
|
if (config.plugins) {
|
|
33
44
|
for (const plugin of config.plugins) {
|
|
34
45
|
this.plugins.register(plugin);
|
|
@@ -53,6 +64,102 @@ export class CriticalPathEngine {
|
|
|
53
64
|
actorIdOr(fallback) {
|
|
54
65
|
return this.actor?.userId ?? fallback;
|
|
55
66
|
}
|
|
67
|
+
// --- Authorization (applies only to withActor views; the base engine is trusted) ---
|
|
68
|
+
/**
|
|
69
|
+
* True when calls must be checked: on a withActor view (not an internal elevated view) with a
|
|
70
|
+
* policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
|
|
71
|
+
*/
|
|
72
|
+
get enforcing() {
|
|
73
|
+
return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
|
|
74
|
+
}
|
|
75
|
+
/** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
|
|
76
|
+
elevated() {
|
|
77
|
+
if (!this.enforcing)
|
|
78
|
+
return this;
|
|
79
|
+
const view = Object.create(this);
|
|
80
|
+
Object.defineProperty(view, 'authorizationBypassed', { value: true });
|
|
81
|
+
return view;
|
|
82
|
+
}
|
|
83
|
+
inActorTenant(entity) {
|
|
84
|
+
const tenant = this.actor?.tenantId;
|
|
85
|
+
return !tenant || entity?.tenantId === tenant;
|
|
86
|
+
}
|
|
87
|
+
async isAllowed(action, project, resource) {
|
|
88
|
+
if (!this.enforcing)
|
|
89
|
+
return true;
|
|
90
|
+
if (project && !this.inActorTenant(project))
|
|
91
|
+
return false;
|
|
92
|
+
const policy = this.config.authorize;
|
|
93
|
+
return policy ? await policy({ actor: this.actor, action, project, resource }) : true;
|
|
94
|
+
}
|
|
95
|
+
/** The project if the actor may read it, otherwise null (callers report "not found"). */
|
|
96
|
+
async readableProject(projectId) {
|
|
97
|
+
if (!projectId)
|
|
98
|
+
return null;
|
|
99
|
+
const project = await this.store.getProject(projectId);
|
|
100
|
+
if (!project)
|
|
101
|
+
return null;
|
|
102
|
+
return (await this.isAllowed('project.read', project)) ? project : null;
|
|
103
|
+
}
|
|
104
|
+
async canReadProject(projectId) {
|
|
105
|
+
if (!this.enforcing)
|
|
106
|
+
return true;
|
|
107
|
+
return (await this.readableProject(projectId)) !== null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
|
|
111
|
+
* that belong to another tenant) are reported as not found so their existence is not revealed.
|
|
112
|
+
*/
|
|
113
|
+
async requireProjectAccess(action, projectId, resource) {
|
|
114
|
+
if (!this.enforcing)
|
|
115
|
+
return;
|
|
116
|
+
const project = await this.readableProject(projectId);
|
|
117
|
+
if (!project)
|
|
118
|
+
throw new NotFoundError(`Project "${projectId}" not found.`);
|
|
119
|
+
if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
|
|
120
|
+
throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async requireWorkspaceAccess(action) {
|
|
124
|
+
if (!(await this.isAllowed(action)))
|
|
125
|
+
throw new ForbiddenError(`Not allowed to ${action}.`);
|
|
126
|
+
}
|
|
127
|
+
/** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
|
|
128
|
+
async filterReadable(items, projectIdOf) {
|
|
129
|
+
if (!this.enforcing)
|
|
130
|
+
return items;
|
|
131
|
+
const readable = new Map();
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
const projectId = await projectIdOf(item);
|
|
135
|
+
if (!projectId)
|
|
136
|
+
continue;
|
|
137
|
+
if (!readable.has(projectId))
|
|
138
|
+
readable.set(projectId, await this.canReadProject(projectId));
|
|
139
|
+
if (readable.get(projectId))
|
|
140
|
+
result.push(item);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
async projectIdOfTask(taskId) {
|
|
145
|
+
return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
|
|
146
|
+
}
|
|
147
|
+
async projectIdOfComment(commentId) {
|
|
148
|
+
if (!commentId)
|
|
149
|
+
return undefined;
|
|
150
|
+
const comment = await this.store.getComment(commentId);
|
|
151
|
+
return this.projectIdOfTask(comment?.taskId);
|
|
152
|
+
}
|
|
153
|
+
async projectIdOfAttachment(a) {
|
|
154
|
+
return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
|
|
155
|
+
}
|
|
156
|
+
/** Strips fields only the engine may set (tenancy) from updates made through a view. */
|
|
157
|
+
withoutTenant(updates) {
|
|
158
|
+
if (!this.actor || !('tenantId' in updates))
|
|
159
|
+
return updates;
|
|
160
|
+
const { tenantId: _ignored, ...rest } = updates;
|
|
161
|
+
return rest;
|
|
162
|
+
}
|
|
56
163
|
async seedInitialData(data) {
|
|
57
164
|
if (data.workflows) {
|
|
58
165
|
for (const wf of data.workflows) {
|
|
@@ -92,13 +199,16 @@ export class CriticalPathEngine {
|
|
|
92
199
|
}
|
|
93
200
|
// --- Workflows ---
|
|
94
201
|
async getWorkflows() {
|
|
95
|
-
|
|
202
|
+
const workflows = await this.store.getWorkflows();
|
|
203
|
+
return workflows.filter((w) => this.inActorTenant(w));
|
|
96
204
|
}
|
|
97
205
|
async getWorkflow(id) {
|
|
98
|
-
|
|
206
|
+
const workflow = await this.store.getWorkflow(id);
|
|
207
|
+
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
99
208
|
}
|
|
100
209
|
async createWorkflow(workflow) {
|
|
101
|
-
|
|
210
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
211
|
+
const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
|
|
102
212
|
const now = new Date().toISOString();
|
|
103
213
|
const event = {
|
|
104
214
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -114,13 +224,14 @@ export class CriticalPathEngine {
|
|
|
114
224
|
action: 'workflow.created',
|
|
115
225
|
details: { name: created.name }
|
|
116
226
|
});
|
|
117
|
-
this.dispatchWebhook('workflow.created', { workflow: created });
|
|
118
227
|
return created;
|
|
119
228
|
}
|
|
120
229
|
async updateWorkflow(id, updates) {
|
|
121
|
-
const existing = await this.
|
|
230
|
+
const existing = await this.getWorkflow(id);
|
|
122
231
|
if (!existing)
|
|
123
232
|
return null;
|
|
233
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
234
|
+
updates = this.withoutTenant(updates);
|
|
124
235
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
125
236
|
if (updated) {
|
|
126
237
|
const now = new Date().toISOString();
|
|
@@ -138,14 +249,14 @@ export class CriticalPathEngine {
|
|
|
138
249
|
action: 'workflow.updated',
|
|
139
250
|
details: { name: updated.name }
|
|
140
251
|
});
|
|
141
|
-
this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
|
|
142
252
|
}
|
|
143
253
|
return updated;
|
|
144
254
|
}
|
|
145
255
|
async deleteWorkflow(id) {
|
|
146
|
-
const existing = await this.
|
|
256
|
+
const existing = await this.getWorkflow(id);
|
|
147
257
|
if (!existing)
|
|
148
258
|
return false;
|
|
259
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
149
260
|
const deleted = await this.store.deleteWorkflow(id);
|
|
150
261
|
if (deleted) {
|
|
151
262
|
const now = new Date().toISOString();
|
|
@@ -155,7 +266,7 @@ export class CriticalPathEngine {
|
|
|
155
266
|
aggregateId: id,
|
|
156
267
|
aggregateType: 'Workflow',
|
|
157
268
|
occurredAt: now,
|
|
158
|
-
payload: { workflowId: id, name: existing.name }
|
|
269
|
+
payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
|
|
159
270
|
};
|
|
160
271
|
await this.events.publish(event);
|
|
161
272
|
await this.store.logActivity({
|
|
@@ -163,7 +274,6 @@ export class CriticalPathEngine {
|
|
|
163
274
|
action: 'workflow.deleted',
|
|
164
275
|
details: { name: existing.name }
|
|
165
276
|
});
|
|
166
|
-
this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
|
|
167
277
|
}
|
|
168
278
|
return deleted;
|
|
169
279
|
}
|
|
@@ -176,7 +286,8 @@ export class CriticalPathEngine {
|
|
|
176
286
|
if (wf)
|
|
177
287
|
return wf;
|
|
178
288
|
}
|
|
179
|
-
|
|
289
|
+
// Only fall back to workflows from the project's own tenant.
|
|
290
|
+
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
|
|
180
291
|
const defaultWf = workflows.find((w) => w.isDefault);
|
|
181
292
|
if (defaultWf)
|
|
182
293
|
return defaultWf;
|
|
@@ -185,21 +296,21 @@ export class CriticalPathEngine {
|
|
|
185
296
|
return DEFAULT_SOFTWARE_WORKFLOW;
|
|
186
297
|
}
|
|
187
298
|
async getAllowedTaskTransitions(taskId) {
|
|
188
|
-
const task = await this.
|
|
299
|
+
const task = await this.getTask(taskId);
|
|
189
300
|
if (!task)
|
|
190
301
|
return [];
|
|
191
302
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
192
303
|
return getAllowedTransitions(workflow || undefined, task.status);
|
|
193
304
|
}
|
|
194
305
|
async getAllowedNextTaskTransitions(taskId) {
|
|
195
|
-
const task = await this.
|
|
306
|
+
const task = await this.getTask(taskId);
|
|
196
307
|
if (!task)
|
|
197
308
|
return [];
|
|
198
309
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
199
310
|
return getAllowedNextStatuses(workflow || undefined, task.status);
|
|
200
311
|
}
|
|
201
312
|
async getAllowedPreviousTaskTransitions(taskId) {
|
|
202
|
-
const task = await this.
|
|
313
|
+
const task = await this.getTask(taskId);
|
|
203
314
|
if (!task)
|
|
204
315
|
return [];
|
|
205
316
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
@@ -207,13 +318,23 @@ export class CriticalPathEngine {
|
|
|
207
318
|
}
|
|
208
319
|
// --- Projects ---
|
|
209
320
|
async getProjects() {
|
|
210
|
-
|
|
321
|
+
const projects = await this.store.getProjects();
|
|
322
|
+
return this.filterReadable(projects, (p) => p.id);
|
|
211
323
|
}
|
|
212
324
|
async getProject(id) {
|
|
213
|
-
|
|
325
|
+
if (!this.enforcing)
|
|
326
|
+
return this.store.getProject(id);
|
|
327
|
+
return this.readableProject(id);
|
|
214
328
|
}
|
|
215
329
|
async createProject(project) {
|
|
216
|
-
|
|
330
|
+
await this.requireWorkspaceAccess('project.create');
|
|
331
|
+
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
332
|
+
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
333
|
+
// The creator administers the project they create.
|
|
334
|
+
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
|
|
335
|
+
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
336
|
+
}
|
|
337
|
+
const created = await this.store.createProject(input);
|
|
217
338
|
const now = new Date().toISOString();
|
|
218
339
|
const event = {
|
|
219
340
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -230,13 +351,16 @@ export class CriticalPathEngine {
|
|
|
230
351
|
action: 'project.created',
|
|
231
352
|
details: { name: created.name, key: created.key }
|
|
232
353
|
});
|
|
233
|
-
this.dispatchWebhook('project.created', { project: created });
|
|
234
354
|
return created;
|
|
235
355
|
}
|
|
236
356
|
async updateProject(id, updates) {
|
|
237
|
-
const existing = await this.
|
|
357
|
+
const existing = await this.getProject(id);
|
|
238
358
|
if (!existing)
|
|
239
359
|
return null;
|
|
360
|
+
await this.requireProjectAccess('project.update', id);
|
|
361
|
+
if ('members' in updates)
|
|
362
|
+
await this.requireProjectAccess('project.manage_members', id);
|
|
363
|
+
updates = this.withoutTenant(updates);
|
|
240
364
|
const updated = await this.store.updateProject(id, updates);
|
|
241
365
|
if (updated) {
|
|
242
366
|
const now = new Date().toISOString();
|
|
@@ -255,7 +379,6 @@ export class CriticalPathEngine {
|
|
|
255
379
|
action: 'project.updated',
|
|
256
380
|
details: { name: updated.name }
|
|
257
381
|
});
|
|
258
|
-
this.dispatchWebhook('project.updated', { project: updated });
|
|
259
382
|
}
|
|
260
383
|
return updated;
|
|
261
384
|
}
|
|
@@ -264,13 +387,16 @@ export class CriticalPathEngine {
|
|
|
264
387
|
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
265
388
|
*/
|
|
266
389
|
async deleteProject(id) {
|
|
267
|
-
const existing = await this.
|
|
390
|
+
const existing = await this.getProject(id);
|
|
268
391
|
if (!existing)
|
|
269
392
|
return false;
|
|
393
|
+
await this.requireProjectAccess('project.delete', id);
|
|
394
|
+
// Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
|
|
395
|
+
const cascade = this.elevated();
|
|
270
396
|
const tasks = await this.store.getTasks(id);
|
|
271
397
|
const deletedTaskIds = [];
|
|
272
398
|
for (const task of tasks) {
|
|
273
|
-
if (await
|
|
399
|
+
if (await cascade.deleteTask(task.id)) {
|
|
274
400
|
deletedTaskIds.push(task.id);
|
|
275
401
|
}
|
|
276
402
|
}
|
|
@@ -283,7 +409,7 @@ export class CriticalPathEngine {
|
|
|
283
409
|
aggregateId: id,
|
|
284
410
|
aggregateType: 'Project',
|
|
285
411
|
occurredAt: now,
|
|
286
|
-
payload: { projectId: id, name: existing.name, deletedTaskIds }
|
|
412
|
+
payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
|
|
287
413
|
};
|
|
288
414
|
await this.events.publish(event);
|
|
289
415
|
await this.store.logActivity({
|
|
@@ -292,18 +418,24 @@ export class CriticalPathEngine {
|
|
|
292
418
|
action: 'project.deleted',
|
|
293
419
|
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
294
420
|
});
|
|
295
|
-
this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
|
|
296
421
|
}
|
|
297
422
|
return deleted;
|
|
298
423
|
}
|
|
299
424
|
// --- Tasks ---
|
|
300
425
|
async getTasks(projectId) {
|
|
301
|
-
|
|
426
|
+
if (projectId && !(await this.canReadProject(projectId)))
|
|
427
|
+
return [];
|
|
428
|
+
const tasks = await this.store.getTasks(projectId);
|
|
429
|
+
return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
|
|
302
430
|
}
|
|
303
431
|
async getTask(id) {
|
|
304
|
-
|
|
432
|
+
const task = await this.store.getTask(id);
|
|
433
|
+
if (!task)
|
|
434
|
+
return null;
|
|
435
|
+
return (await this.canReadProject(task.projectId)) ? task : null;
|
|
305
436
|
}
|
|
306
437
|
async createTask(taskInput) {
|
|
438
|
+
await this.requireProjectAccess('task.create', taskInput.projectId);
|
|
307
439
|
if (this.actor && !taskInput.reporterId) {
|
|
308
440
|
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
309
441
|
}
|
|
@@ -385,13 +517,13 @@ export class CriticalPathEngine {
|
|
|
385
517
|
action: 'task.created',
|
|
386
518
|
details: { title: created.title, status: created.status }
|
|
387
519
|
});
|
|
388
|
-
this.dispatchWebhook('task.created', { task: created });
|
|
389
520
|
return created;
|
|
390
521
|
}
|
|
391
522
|
async updateTask(id, updates, options) {
|
|
392
|
-
const existing = await this.
|
|
523
|
+
const existing = await this.getTask(id);
|
|
393
524
|
if (!existing)
|
|
394
525
|
return null;
|
|
526
|
+
await this.requireProjectAccess('task.update', existing.projectId);
|
|
395
527
|
// Identity comes from trusted options or the withActor view, never from the update payload.
|
|
396
528
|
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
397
529
|
const actorId = options?.actorId || options?.actor?.userId || 'system';
|
|
@@ -629,10 +761,6 @@ export class CriticalPathEngine {
|
|
|
629
761
|
action: 'task.blocked',
|
|
630
762
|
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
631
763
|
});
|
|
632
|
-
this.dispatchWebhook('task.blocked', {
|
|
633
|
-
task: updated,
|
|
634
|
-
reason: blockedReason
|
|
635
|
-
});
|
|
636
764
|
}
|
|
637
765
|
else if (wasBlocked && !isNowBlocked) {
|
|
638
766
|
const unblockedEvent = {
|
|
@@ -656,9 +784,6 @@ export class CriticalPathEngine {
|
|
|
656
784
|
action: 'task.unblocked',
|
|
657
785
|
details: { ...(actorName ? { actorName } : {}) }
|
|
658
786
|
});
|
|
659
|
-
this.dispatchWebhook('task.unblocked', {
|
|
660
|
-
task: updated
|
|
661
|
-
});
|
|
662
787
|
}
|
|
663
788
|
await this.store.logActivity({
|
|
664
789
|
projectId: updated.projectId,
|
|
@@ -671,10 +796,6 @@ export class CriticalPathEngine {
|
|
|
671
796
|
...(actorName ? { actorName } : {})
|
|
672
797
|
}
|
|
673
798
|
});
|
|
674
|
-
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
|
675
|
-
task: updated,
|
|
676
|
-
previous: existing
|
|
677
|
-
});
|
|
678
799
|
return updated;
|
|
679
800
|
}
|
|
680
801
|
async checkAndUnblockDownstreamTasks(completedTask) {
|
|
@@ -736,10 +857,6 @@ export class CriticalPathEngine {
|
|
|
736
857
|
action: 'task.unblocked',
|
|
737
858
|
details: { unblockedByTaskId: completedTask.id }
|
|
738
859
|
});
|
|
739
|
-
this.dispatchWebhook('task.unblocked', {
|
|
740
|
-
task: updatedDownstream,
|
|
741
|
-
upstreamTaskId: completedTask.id
|
|
742
|
-
});
|
|
743
860
|
}
|
|
744
861
|
}
|
|
745
862
|
}
|
|
@@ -748,9 +865,10 @@ export class CriticalPathEngine {
|
|
|
748
865
|
}
|
|
749
866
|
}
|
|
750
867
|
async deleteTask(id) {
|
|
751
|
-
const existing = await this.
|
|
868
|
+
const existing = await this.getTask(id);
|
|
752
869
|
if (!existing)
|
|
753
870
|
return false;
|
|
871
|
+
await this.requireProjectAccess('task.delete', existing.projectId);
|
|
754
872
|
await this.plugins.runBeforeTaskDelete(id);
|
|
755
873
|
const deleted = await this.store.deleteTask(id);
|
|
756
874
|
if (deleted) {
|
|
@@ -776,12 +894,19 @@ export class CriticalPathEngine {
|
|
|
776
894
|
action: 'task.deleted',
|
|
777
895
|
details: { title: existing.title }
|
|
778
896
|
});
|
|
779
|
-
this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
|
|
780
897
|
}
|
|
781
898
|
return deleted;
|
|
782
899
|
}
|
|
783
900
|
// --- Task Dependencies & Graph ---
|
|
784
901
|
async addDependency(dep) {
|
|
902
|
+
if (this.enforcing) {
|
|
903
|
+
const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
|
|
904
|
+
if (!task)
|
|
905
|
+
throw new NotFoundError(`Task "${dep.taskId}" not found.`);
|
|
906
|
+
if (!upstream)
|
|
907
|
+
throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
|
|
908
|
+
await this.requireProjectAccess('task.update', task.projectId);
|
|
909
|
+
}
|
|
785
910
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
786
911
|
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
787
912
|
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
@@ -830,6 +955,9 @@ export class CriticalPathEngine {
|
|
|
830
955
|
return Array.from(edges.values());
|
|
831
956
|
}
|
|
832
957
|
async getTaskDependencyGraph(taskId) {
|
|
958
|
+
if (this.enforcing && !(await this.getTask(taskId))) {
|
|
959
|
+
return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
|
|
960
|
+
}
|
|
833
961
|
const dependencies = await this.store.getDependencies(taskId);
|
|
834
962
|
const upstreamTaskIds = new Set();
|
|
835
963
|
const downstreamTaskIds = new Set();
|
|
@@ -841,8 +969,8 @@ export class CriticalPathEngine {
|
|
|
841
969
|
downstreamTaskIds.add(dep.taskId);
|
|
842
970
|
}
|
|
843
971
|
}
|
|
844
|
-
const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.
|
|
845
|
-
const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
972
|
+
const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
|
|
973
|
+
const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
|
|
846
974
|
return {
|
|
847
975
|
taskId,
|
|
848
976
|
upstreamTasks,
|
|
@@ -871,7 +999,12 @@ export class CriticalPathEngine {
|
|
|
871
999
|
if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
|
|
872
1000
|
throw new ValidationError('Logged hours must be a positive number.');
|
|
873
1001
|
}
|
|
874
|
-
const task = await this.
|
|
1002
|
+
const task = await this.getTask(entry.taskId);
|
|
1003
|
+
if (this.enforcing) {
|
|
1004
|
+
if (!task)
|
|
1005
|
+
throw new NotFoundError(`Task "${entry.taskId}" not found.`);
|
|
1006
|
+
await this.requireProjectAccess('time.log', task.projectId);
|
|
1007
|
+
}
|
|
875
1008
|
if (task) {
|
|
876
1009
|
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
877
1010
|
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
@@ -902,12 +1035,32 @@ export class CriticalPathEngine {
|
|
|
902
1035
|
}
|
|
903
1036
|
// --- Comments ---
|
|
904
1037
|
async getComments(taskId) {
|
|
1038
|
+
if (this.enforcing && !(await this.getTask(taskId)))
|
|
1039
|
+
return [];
|
|
905
1040
|
return this.store.getComments(taskId);
|
|
906
1041
|
}
|
|
907
1042
|
async getComment(id) {
|
|
908
|
-
|
|
1043
|
+
const comment = await this.store.getComment(id);
|
|
1044
|
+
if (!comment)
|
|
1045
|
+
return null;
|
|
1046
|
+
return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
|
|
1047
|
+
}
|
|
1048
|
+
async getActivities(filter) {
|
|
1049
|
+
const activities = await this.store.getActivities(filter);
|
|
1050
|
+
return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
|
|
1051
|
+
}
|
|
1052
|
+
async getTimeEntries(taskId) {
|
|
1053
|
+
if (this.enforcing && !(await this.getTask(taskId)))
|
|
1054
|
+
return [];
|
|
1055
|
+
return this.store.getTimeEntries(taskId);
|
|
909
1056
|
}
|
|
910
1057
|
async addComment(input) {
|
|
1058
|
+
if (this.enforcing) {
|
|
1059
|
+
const task = await this.getTask(input.taskId);
|
|
1060
|
+
if (!task)
|
|
1061
|
+
throw new NotFoundError(`Task "${input.taskId}" not found.`);
|
|
1062
|
+
await this.requireProjectAccess('comment.create', task.projectId);
|
|
1063
|
+
}
|
|
911
1064
|
const comment = this.actor
|
|
912
1065
|
? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
|
|
913
1066
|
: { ...input, authorId: input.authorId ?? 'system' };
|
|
@@ -923,13 +1076,16 @@ export class CriticalPathEngine {
|
|
|
923
1076
|
payload: { taskId: comment.taskId, comment: created }
|
|
924
1077
|
};
|
|
925
1078
|
await this.events.publish(event);
|
|
926
|
-
this.dispatchWebhook('comment.created', { comment: created });
|
|
927
1079
|
return created;
|
|
928
1080
|
}
|
|
929
1081
|
async updateComment(id, updates) {
|
|
930
|
-
const existing = await this.
|
|
1082
|
+
const existing = await this.getComment(id);
|
|
931
1083
|
if (!existing)
|
|
932
1084
|
return null;
|
|
1085
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1086
|
+
type: 'comment',
|
|
1087
|
+
ownerId: existing.authorId
|
|
1088
|
+
});
|
|
933
1089
|
const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
|
|
934
1090
|
const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
|
|
935
1091
|
const updated = await this.store.updateComment(id, toUpdate);
|
|
@@ -945,13 +1101,16 @@ export class CriticalPathEngine {
|
|
|
945
1101
|
payload: { comment: updated, previous: existing }
|
|
946
1102
|
};
|
|
947
1103
|
await this.events.publish(event);
|
|
948
|
-
this.dispatchWebhook('comment.updated', { comment: updated });
|
|
949
1104
|
return updated;
|
|
950
1105
|
}
|
|
951
1106
|
async deleteComment(id) {
|
|
952
|
-
const existing = await this.
|
|
1107
|
+
const existing = await this.getComment(id);
|
|
953
1108
|
if (!existing)
|
|
954
1109
|
return false;
|
|
1110
|
+
await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
|
|
1111
|
+
type: 'comment',
|
|
1112
|
+
ownerId: existing.authorId
|
|
1113
|
+
});
|
|
955
1114
|
const deleted = await this.store.deleteComment(id);
|
|
956
1115
|
if (deleted) {
|
|
957
1116
|
const now = new Date().toISOString();
|
|
@@ -964,15 +1123,15 @@ export class CriticalPathEngine {
|
|
|
964
1123
|
payload: { taskId: existing.taskId, commentId: id }
|
|
965
1124
|
};
|
|
966
1125
|
await this.events.publish(event);
|
|
967
|
-
this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
|
|
968
1126
|
}
|
|
969
1127
|
return deleted;
|
|
970
1128
|
}
|
|
971
1129
|
async addCommentReaction(commentId, input) {
|
|
972
1130
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
973
|
-
const existing = await this.
|
|
1131
|
+
const existing = await this.getComment(commentId);
|
|
974
1132
|
if (!existing)
|
|
975
1133
|
return null;
|
|
1134
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
976
1135
|
let updated;
|
|
977
1136
|
if (this.store.addReaction) {
|
|
978
1137
|
updated = await this.store.addReaction(commentId, reaction);
|
|
@@ -1009,14 +1168,14 @@ export class CriticalPathEngine {
|
|
|
1009
1168
|
}
|
|
1010
1169
|
};
|
|
1011
1170
|
await this.events.publish(event);
|
|
1012
|
-
this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
|
|
1013
1171
|
return updated;
|
|
1014
1172
|
}
|
|
1015
1173
|
async removeCommentReaction(commentId, input) {
|
|
1016
1174
|
const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
|
|
1017
|
-
const existing = await this.
|
|
1175
|
+
const existing = await this.getComment(commentId);
|
|
1018
1176
|
if (!existing)
|
|
1019
1177
|
return null;
|
|
1178
|
+
await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
|
|
1020
1179
|
let updated;
|
|
1021
1180
|
if (this.store.removeReaction) {
|
|
1022
1181
|
updated = await this.store.removeReaction(commentId, reaction);
|
|
@@ -1037,21 +1196,32 @@ export class CriticalPathEngine {
|
|
|
1037
1196
|
payload: { commentId, reaction, comment: updated }
|
|
1038
1197
|
};
|
|
1039
1198
|
await this.events.publish(event);
|
|
1040
|
-
this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
|
|
1041
1199
|
return updated;
|
|
1042
1200
|
}
|
|
1043
1201
|
// --- Attachments ---
|
|
1044
1202
|
async getAttachments(filter) {
|
|
1045
|
-
const attachments = await this.store.getAttachments(filter);
|
|
1203
|
+
const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) => this.projectIdOfAttachment(a));
|
|
1046
1204
|
if (filter?.artifactType) {
|
|
1047
1205
|
return attachments.filter(a => a.artifactType === filter.artifactType);
|
|
1048
1206
|
}
|
|
1049
1207
|
return attachments;
|
|
1050
1208
|
}
|
|
1051
1209
|
async getAttachment(id) {
|
|
1052
|
-
|
|
1210
|
+
const attachment = await this.store.getAttachment(id);
|
|
1211
|
+
if (!attachment)
|
|
1212
|
+
return null;
|
|
1213
|
+
return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
|
|
1214
|
+
}
|
|
1215
|
+
async requireAttachmentCreate(link) {
|
|
1216
|
+
if (!this.enforcing)
|
|
1217
|
+
return;
|
|
1218
|
+
const projectId = await this.projectIdOfAttachment(link);
|
|
1219
|
+
if (!projectId)
|
|
1220
|
+
throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1221
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1053
1222
|
}
|
|
1054
1223
|
async createAttachment(input) {
|
|
1224
|
+
await this.requireAttachmentCreate(input);
|
|
1055
1225
|
const attachment = this.actor
|
|
1056
1226
|
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
1057
1227
|
: { ...input, uploaderId: input.uploaderId ?? 'system' };
|
|
@@ -1067,13 +1237,16 @@ export class CriticalPathEngine {
|
|
|
1067
1237
|
payload: { attachment: created }
|
|
1068
1238
|
};
|
|
1069
1239
|
await this.events.publish(event);
|
|
1070
|
-
this.dispatchWebhook('attachment.created', { attachment: created });
|
|
1071
1240
|
return created;
|
|
1072
1241
|
}
|
|
1073
1242
|
async deleteAttachment(id) {
|
|
1074
|
-
const existing = await this.
|
|
1243
|
+
const existing = await this.getAttachment(id);
|
|
1075
1244
|
if (!existing)
|
|
1076
1245
|
return false;
|
|
1246
|
+
await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
|
|
1247
|
+
type: 'attachment',
|
|
1248
|
+
ownerId: existing.uploaderId
|
|
1249
|
+
});
|
|
1077
1250
|
if (existing.storageKey && this.fileStorage) {
|
|
1078
1251
|
try {
|
|
1079
1252
|
await this.fileStorage.delete(existing.storageKey);
|
|
@@ -1091,10 +1264,14 @@ export class CriticalPathEngine {
|
|
|
1091
1264
|
aggregateId: id,
|
|
1092
1265
|
aggregateType: 'Attachment',
|
|
1093
1266
|
occurredAt: now,
|
|
1094
|
-
payload: {
|
|
1267
|
+
payload: {
|
|
1268
|
+
attachmentId: id,
|
|
1269
|
+
storageKey: existing.storageKey,
|
|
1270
|
+
url: existing.url,
|
|
1271
|
+
projectId: await this.projectIdOfAttachment(existing)
|
|
1272
|
+
}
|
|
1095
1273
|
};
|
|
1096
1274
|
await this.events.publish(event);
|
|
1097
|
-
this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
|
|
1098
1275
|
}
|
|
1099
1276
|
return deleted;
|
|
1100
1277
|
}
|
|
@@ -1102,6 +1279,8 @@ export class CriticalPathEngine {
|
|
|
1102
1279
|
if (!this.fileStorage) {
|
|
1103
1280
|
throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
|
|
1104
1281
|
}
|
|
1282
|
+
// Check before uploading so denied requests do not leave orphaned files behind.
|
|
1283
|
+
await this.requireAttachmentCreate(input);
|
|
1105
1284
|
const uploadResult = await this.fileStorage.upload({
|
|
1106
1285
|
filename: input.filename,
|
|
1107
1286
|
data: input.data,
|
|
@@ -1155,17 +1334,33 @@ export class CriticalPathEngine {
|
|
|
1155
1334
|
if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
|
|
1156
1335
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1157
1336
|
}
|
|
1158
|
-
|
|
1337
|
+
const { projectId, ...presign } = options;
|
|
1338
|
+
if (this.enforcing) {
|
|
1339
|
+
// Scoped callers may only write under their project's prefix.
|
|
1340
|
+
if (!projectId)
|
|
1341
|
+
throw new ValidationError('projectId is required for presigned uploads.');
|
|
1342
|
+
await this.requireProjectAccess('attachment.create', projectId);
|
|
1343
|
+
const prefix = `projects/${projectId}/`;
|
|
1344
|
+
const key = presign.storageKey.replace(/^\/+/, '');
|
|
1345
|
+
if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
1346
|
+
throw new ValidationError('storageKey must not contain "." or ".." segments.');
|
|
1347
|
+
}
|
|
1348
|
+
presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
|
|
1349
|
+
}
|
|
1350
|
+
return this.fileStorage.getPresignedUploadUrl(presign);
|
|
1159
1351
|
}
|
|
1160
1352
|
// --- Teams ---
|
|
1161
1353
|
async getTeams() {
|
|
1162
|
-
|
|
1354
|
+
const teams = await this.store.getTeams();
|
|
1355
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1163
1356
|
}
|
|
1164
1357
|
async getTeam(id) {
|
|
1165
|
-
|
|
1358
|
+
const team = await this.store.getTeam(id);
|
|
1359
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1166
1360
|
}
|
|
1167
1361
|
async createTeam(team) {
|
|
1168
|
-
|
|
1362
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1363
|
+
const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
|
|
1169
1364
|
const now = new Date().toISOString();
|
|
1170
1365
|
const event = {
|
|
1171
1366
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1176,23 +1371,34 @@ export class CriticalPathEngine {
|
|
|
1176
1371
|
payload: { team: created }
|
|
1177
1372
|
};
|
|
1178
1373
|
await this.events.publish(event);
|
|
1179
|
-
this.dispatchWebhook('team.created', { team: created });
|
|
1180
1374
|
return created;
|
|
1181
1375
|
}
|
|
1182
1376
|
async updateTeam(id, updates) {
|
|
1183
|
-
|
|
1377
|
+
if (!(await this.getTeam(id)))
|
|
1378
|
+
return null;
|
|
1379
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1380
|
+
return this.store.updateTeam(id, this.withoutTenant(updates));
|
|
1184
1381
|
}
|
|
1185
1382
|
async deleteTeam(id) {
|
|
1383
|
+
if (!(await this.getTeam(id)))
|
|
1384
|
+
return false;
|
|
1385
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1186
1386
|
return this.store.deleteTeam(id);
|
|
1187
1387
|
}
|
|
1188
1388
|
// --- Containers ---
|
|
1189
1389
|
async getContainers(projectId) {
|
|
1390
|
+
if (!(await this.canReadProject(projectId)))
|
|
1391
|
+
return [];
|
|
1190
1392
|
return this.store.getContainers(projectId);
|
|
1191
1393
|
}
|
|
1192
1394
|
async getContainer(id) {
|
|
1193
|
-
|
|
1395
|
+
const container = await this.store.getContainer(id);
|
|
1396
|
+
if (!container)
|
|
1397
|
+
return null;
|
|
1398
|
+
return (await this.canReadProject(container.projectId)) ? container : null;
|
|
1194
1399
|
}
|
|
1195
1400
|
async createContainer(container) {
|
|
1401
|
+
await this.requireProjectAccess('plan.manage', container.projectId);
|
|
1196
1402
|
const created = await this.store.createContainer(container);
|
|
1197
1403
|
const now = new Date().toISOString();
|
|
1198
1404
|
const event = {
|
|
@@ -1204,23 +1410,36 @@ export class CriticalPathEngine {
|
|
|
1204
1410
|
payload: { container: created }
|
|
1205
1411
|
};
|
|
1206
1412
|
await this.events.publish(event);
|
|
1207
|
-
this.dispatchWebhook('container.created', { container: created });
|
|
1208
1413
|
return created;
|
|
1209
1414
|
}
|
|
1210
1415
|
async updateContainer(id, updates) {
|
|
1416
|
+
const existing = await this.getContainer(id);
|
|
1417
|
+
if (!existing)
|
|
1418
|
+
return null;
|
|
1419
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1211
1420
|
return this.store.updateContainer(id, updates);
|
|
1212
1421
|
}
|
|
1213
1422
|
async deleteContainer(id) {
|
|
1423
|
+
const existing = await this.getContainer(id);
|
|
1424
|
+
if (!existing)
|
|
1425
|
+
return false;
|
|
1426
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1214
1427
|
return this.store.deleteContainer(id);
|
|
1215
1428
|
}
|
|
1216
1429
|
// --- Deliverables ---
|
|
1217
1430
|
async getDeliverables(projectId) {
|
|
1431
|
+
if (!(await this.canReadProject(projectId)))
|
|
1432
|
+
return [];
|
|
1218
1433
|
return this.store.getDeliverables(projectId);
|
|
1219
1434
|
}
|
|
1220
1435
|
async getDeliverable(id) {
|
|
1221
|
-
|
|
1436
|
+
const deliverable = await this.store.getDeliverable(id);
|
|
1437
|
+
if (!deliverable)
|
|
1438
|
+
return null;
|
|
1439
|
+
return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
|
|
1222
1440
|
}
|
|
1223
1441
|
async createDeliverable(input) {
|
|
1442
|
+
await this.requireProjectAccess('plan.manage', input.projectId);
|
|
1224
1443
|
const project = await this.store.getProject(input.projectId);
|
|
1225
1444
|
if (project?.customFieldDefinitions && input.customFields) {
|
|
1226
1445
|
validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
|
|
@@ -1247,13 +1466,13 @@ export class CriticalPathEngine {
|
|
|
1247
1466
|
action: 'deliverable.created',
|
|
1248
1467
|
details: { title: created.title, format: created.format }
|
|
1249
1468
|
});
|
|
1250
|
-
this.dispatchWebhook('deliverable.created', { deliverable: created });
|
|
1251
1469
|
return created;
|
|
1252
1470
|
}
|
|
1253
1471
|
async updateDeliverable(id, updates) {
|
|
1254
|
-
const existing = await this.
|
|
1472
|
+
const existing = await this.getDeliverable(id);
|
|
1255
1473
|
if (!existing)
|
|
1256
1474
|
return null;
|
|
1475
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1257
1476
|
if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
|
|
1258
1477
|
updates.deliveredAt = new Date().toISOString();
|
|
1259
1478
|
}
|
|
@@ -1274,11 +1493,6 @@ export class CriticalPathEngine {
|
|
|
1274
1493
|
}
|
|
1275
1494
|
};
|
|
1276
1495
|
await this.events.publish(statusEvent);
|
|
1277
|
-
this.dispatchWebhook('deliverable.status_changed', {
|
|
1278
|
-
deliverable: updated,
|
|
1279
|
-
previousStatus: existing.status,
|
|
1280
|
-
newStatus: updated.status
|
|
1281
|
-
});
|
|
1282
1496
|
}
|
|
1283
1497
|
const updateEvent = {
|
|
1284
1498
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1295,14 +1509,14 @@ export class CriticalPathEngine {
|
|
|
1295
1509
|
action: 'deliverable.updated',
|
|
1296
1510
|
details: { title: updated.title, status: updated.status }
|
|
1297
1511
|
});
|
|
1298
|
-
this.dispatchWebhook('deliverable.updated', { deliverable: updated });
|
|
1299
1512
|
}
|
|
1300
1513
|
return updated;
|
|
1301
1514
|
}
|
|
1302
1515
|
async deleteDeliverable(id) {
|
|
1303
|
-
const existing = await this.
|
|
1516
|
+
const existing = await this.getDeliverable(id);
|
|
1304
1517
|
if (!existing)
|
|
1305
1518
|
return false;
|
|
1519
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1306
1520
|
const deleted = await this.store.deleteDeliverable(id);
|
|
1307
1521
|
if (deleted) {
|
|
1308
1522
|
const now = new Date().toISOString();
|
|
@@ -1321,12 +1535,11 @@ export class CriticalPathEngine {
|
|
|
1321
1535
|
action: 'deliverable.deleted',
|
|
1322
1536
|
details: { title: existing.title }
|
|
1323
1537
|
});
|
|
1324
|
-
this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
|
|
1325
1538
|
}
|
|
1326
1539
|
return deleted;
|
|
1327
1540
|
}
|
|
1328
1541
|
async getDeliverableSummary(deliverableId) {
|
|
1329
|
-
const deliverable = await this.
|
|
1542
|
+
const deliverable = await this.getDeliverable(deliverableId);
|
|
1330
1543
|
if (!deliverable)
|
|
1331
1544
|
return null;
|
|
1332
1545
|
const project = await this.store.getProject(deliverable.projectId);
|
|
@@ -1371,12 +1584,18 @@ export class CriticalPathEngine {
|
|
|
1371
1584
|
}
|
|
1372
1585
|
// --- Iterations ---
|
|
1373
1586
|
async getIterations(projectId) {
|
|
1587
|
+
if (!(await this.canReadProject(projectId)))
|
|
1588
|
+
return [];
|
|
1374
1589
|
return this.store.getIterations(projectId);
|
|
1375
1590
|
}
|
|
1376
1591
|
async getIteration(id) {
|
|
1377
|
-
|
|
1592
|
+
const iteration = await this.store.getIteration(id);
|
|
1593
|
+
if (!iteration)
|
|
1594
|
+
return null;
|
|
1595
|
+
return (await this.canReadProject(iteration.projectId)) ? iteration : null;
|
|
1378
1596
|
}
|
|
1379
1597
|
async createIteration(iteration) {
|
|
1598
|
+
await this.requireProjectAccess('plan.manage', iteration.projectId);
|
|
1380
1599
|
const created = await this.store.createIteration(iteration);
|
|
1381
1600
|
if (created.status === 'active') {
|
|
1382
1601
|
const now = new Date().toISOString();
|
|
@@ -1389,14 +1608,14 @@ export class CriticalPathEngine {
|
|
|
1389
1608
|
payload: { iteration: created }
|
|
1390
1609
|
};
|
|
1391
1610
|
await this.events.publish(event);
|
|
1392
|
-
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
1393
1611
|
}
|
|
1394
1612
|
return created;
|
|
1395
1613
|
}
|
|
1396
1614
|
async updateIteration(id, updates) {
|
|
1397
|
-
const existing = await this.
|
|
1615
|
+
const existing = await this.getIteration(id);
|
|
1398
1616
|
if (!existing)
|
|
1399
1617
|
return null;
|
|
1618
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1400
1619
|
const updated = await this.store.updateIteration(id, updates);
|
|
1401
1620
|
if (updated) {
|
|
1402
1621
|
const now = new Date().toISOString();
|
|
@@ -1410,7 +1629,6 @@ export class CriticalPathEngine {
|
|
|
1410
1629
|
payload: { iteration: updated }
|
|
1411
1630
|
};
|
|
1412
1631
|
await this.events.publish(event);
|
|
1413
|
-
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
1414
1632
|
}
|
|
1415
1633
|
else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
1416
1634
|
const event = {
|
|
@@ -1422,16 +1640,20 @@ export class CriticalPathEngine {
|
|
|
1422
1640
|
payload: { iteration: updated }
|
|
1423
1641
|
};
|
|
1424
1642
|
await this.events.publish(event);
|
|
1425
|
-
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
1426
1643
|
}
|
|
1427
1644
|
}
|
|
1428
1645
|
return updated;
|
|
1429
1646
|
}
|
|
1430
1647
|
async deleteIteration(id) {
|
|
1648
|
+
const existing = await this.getIteration(id);
|
|
1649
|
+
if (!existing)
|
|
1650
|
+
return false;
|
|
1651
|
+
await this.requireProjectAccess('plan.manage', existing.projectId);
|
|
1431
1652
|
return this.store.deleteIteration(id);
|
|
1432
1653
|
}
|
|
1433
1654
|
// --- Ladder of Abstraction & Critical Path Method ---
|
|
1434
1655
|
async calculateCriticalPath(projectId, options = {}) {
|
|
1656
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1435
1657
|
const project = await this.store.getProject(projectId);
|
|
1436
1658
|
const tasks = await this.store.getTasks(projectId);
|
|
1437
1659
|
const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
|
|
@@ -1450,6 +1672,7 @@ export class CriticalPathEngine {
|
|
|
1450
1672
|
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
|
|
1451
1673
|
}
|
|
1452
1674
|
async getTimelineLadder(projectId, options = {}) {
|
|
1675
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1453
1676
|
const project = await this.store.getProject(projectId);
|
|
1454
1677
|
if (!project) {
|
|
1455
1678
|
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
@@ -1496,7 +1719,7 @@ export class CriticalPathEngine {
|
|
|
1496
1719
|
}, options);
|
|
1497
1720
|
}
|
|
1498
1721
|
async getTaskLadder(taskId) {
|
|
1499
|
-
const task = await this.
|
|
1722
|
+
const task = await this.getTask(taskId);
|
|
1500
1723
|
if (!task)
|
|
1501
1724
|
return null;
|
|
1502
1725
|
const project = await this.store.getProject(task.projectId);
|
|
@@ -1518,7 +1741,7 @@ export class CriticalPathEngine {
|
|
|
1518
1741
|
};
|
|
1519
1742
|
}
|
|
1520
1743
|
async getTaskMetrics(taskId, options) {
|
|
1521
|
-
const task = await this.
|
|
1744
|
+
const task = await this.getTask(taskId);
|
|
1522
1745
|
if (!task)
|
|
1523
1746
|
return null;
|
|
1524
1747
|
const [activities, timeEntries, project, workflow] = await Promise.all([
|
|
@@ -1534,7 +1757,7 @@ export class CriticalPathEngine {
|
|
|
1534
1757
|
});
|
|
1535
1758
|
}
|
|
1536
1759
|
async getTaskProgressHistory(taskId, options) {
|
|
1537
|
-
const task = await this.
|
|
1760
|
+
const task = await this.getTask(taskId);
|
|
1538
1761
|
if (!task)
|
|
1539
1762
|
return null;
|
|
1540
1763
|
const [activities, project, workflow] = await Promise.all([
|
|
@@ -1549,8 +1772,10 @@ export class CriticalPathEngine {
|
|
|
1549
1772
|
});
|
|
1550
1773
|
}
|
|
1551
1774
|
async getWorkloadDistribution(projectId, options = {}) {
|
|
1552
|
-
|
|
1553
|
-
|
|
1775
|
+
if (projectId)
|
|
1776
|
+
await this.requireProjectAccess('project.read', projectId);
|
|
1777
|
+
const tasks = await this.getTasks(projectId);
|
|
1778
|
+
const teams = await this.getTeams();
|
|
1554
1779
|
const project = projectId ? await this.store.getProject(projectId) : undefined;
|
|
1555
1780
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
1556
1781
|
const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
|
|
@@ -1565,18 +1790,93 @@ export class CriticalPathEngine {
|
|
|
1565
1790
|
schedule
|
|
1566
1791
|
});
|
|
1567
1792
|
}
|
|
1568
|
-
|
|
1793
|
+
// --- Webhooks ---
|
|
1794
|
+
/** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
|
|
1795
|
+
async listAllWebhooks() {
|
|
1796
|
+
const fromConfig = (this.config.webhooks ?? []).map((w, i) => ({
|
|
1797
|
+
...w,
|
|
1798
|
+
id: `config_${i}`,
|
|
1799
|
+
createdAt: new Date(0).toISOString()
|
|
1800
|
+
}));
|
|
1801
|
+
return [...fromConfig, ...(await this.store.getWebhooks())];
|
|
1802
|
+
}
|
|
1803
|
+
toPublicWebhook({ secret, ...webhook }) {
|
|
1804
|
+
return { ...webhook, hasSecret: !!secret };
|
|
1805
|
+
}
|
|
1806
|
+
/** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
|
|
1807
|
+
async getWebhooks() {
|
|
1808
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1569
1809
|
const webhooks = await this.store.getWebhooks();
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1810
|
+
return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
|
|
1811
|
+
}
|
|
1812
|
+
async getWebhook(id) {
|
|
1813
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1814
|
+
const webhook = await this.store.getWebhook(id);
|
|
1815
|
+
return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Registers a webhook. The signing secret is returned only here; generate one by omitting
|
|
1819
|
+
* `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
|
|
1820
|
+
*/
|
|
1821
|
+
async createWebhook(input) {
|
|
1822
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
1823
|
+
this.validateWebhookUrl(input.url);
|
|
1824
|
+
const secret = input.secret ?? generateWebhookSecret();
|
|
1825
|
+
const created = await this.store.addWebhook({
|
|
1826
|
+
name: input.name,
|
|
1827
|
+
url: input.url,
|
|
1828
|
+
events: input.events,
|
|
1829
|
+
secret,
|
|
1830
|
+
active: input.active ?? true,
|
|
1831
|
+
tenantId: this.actor?.tenantId
|
|
1832
|
+
});
|
|
1833
|
+
this.webhooks.invalidate();
|
|
1834
|
+
return { webhook: this.toPublicWebhook(created), secret };
|
|
1835
|
+
}
|
|
1836
|
+
async updateWebhook(id, updates) {
|
|
1837
|
+
if (!(await this.getWebhook(id)))
|
|
1838
|
+
return null;
|
|
1839
|
+
if (updates.url)
|
|
1840
|
+
this.validateWebhookUrl(updates.url);
|
|
1841
|
+
const { name, url, events, active, secret } = updates;
|
|
1842
|
+
const updated = await this.store.updateWebhook(id, Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined)));
|
|
1843
|
+
this.webhooks.invalidate();
|
|
1844
|
+
return updated ? this.toPublicWebhook(updated) : null;
|
|
1845
|
+
}
|
|
1846
|
+
async deleteWebhook(id) {
|
|
1847
|
+
if (!(await this.getWebhook(id)))
|
|
1848
|
+
return false;
|
|
1849
|
+
const deleted = await this.store.deleteWebhook(id);
|
|
1850
|
+
this.webhooks.invalidate();
|
|
1851
|
+
return deleted;
|
|
1852
|
+
}
|
|
1853
|
+
validateWebhookUrl(url) {
|
|
1854
|
+
try {
|
|
1855
|
+
assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
|
|
1579
1856
|
}
|
|
1857
|
+
catch (err) {
|
|
1858
|
+
throw new ValidationError(err.message);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
/** Finds the tenant an event belongs to, from the entity in its payload or its project. */
|
|
1862
|
+
async resolveEventTenant(event) {
|
|
1863
|
+
const p = event.payload;
|
|
1864
|
+
const owner = p.project ?? p.workflow ?? p.team;
|
|
1865
|
+
if (owner)
|
|
1866
|
+
return owner.tenantId ?? undefined;
|
|
1867
|
+
// Deleted projects and workflows carry their tenant in the payload.
|
|
1868
|
+
if ('tenantId' in p)
|
|
1869
|
+
return p.tenantId ?? undefined;
|
|
1870
|
+
const projectId = p.projectId ??
|
|
1871
|
+
p.task?.projectId ??
|
|
1872
|
+
p.deliverable?.projectId ??
|
|
1873
|
+
p.iteration?.projectId ??
|
|
1874
|
+
p.container?.projectId ??
|
|
1875
|
+
p.attachment?.projectId ??
|
|
1876
|
+
(await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
|
|
1877
|
+
if (!projectId)
|
|
1878
|
+
return undefined;
|
|
1879
|
+
return (await this.store.getProject(projectId))?.tenantId ?? undefined;
|
|
1580
1880
|
}
|
|
1581
1881
|
}
|
|
1582
1882
|
//# sourceMappingURL=index.js.map
|