@critical-path/server 0.13.5 → 0.15.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 +63 -0
- package/README.md +68 -0
- package/dist/adapters/next.d.ts +18 -8
- package/dist/adapters/next.d.ts.map +1 -1
- package/dist/adapters/next.js +11 -4
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/sveltekit.d.ts +18 -20
- package/dist/adapters/sveltekit.d.ts.map +1 -1
- package/dist/adapters/sveltekit.js +5 -4
- package/dist/adapters/sveltekit.js.map +1 -1
- package/dist/adapters/universal.d.ts +8 -0
- package/dist/adapters/universal.d.ts.map +1 -0
- package/dist/adapters/universal.js +12 -0
- package/dist/adapters/universal.js.map +1 -0
- 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/index.test.js +284 -0
- package/dist/index.test.js.map +1 -1
- package/dist/router.d.ts +72 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +274 -124
- package/dist/router.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/next.ts +27 -6
- package/src/adapters/sveltekit.ts +22 -5
- package/src/adapters/universal.ts +16 -0
- package/src/index.test.ts +342 -0
- package/src/index.ts +1 -0
- package/src/router.ts +326 -125
- package/tsconfig.tsbuildinfo +1 -1
package/src/router.ts
CHANGED
|
@@ -1,54 +1,207 @@
|
|
|
1
|
-
import { CriticalPathEngine, type CriticalPathConfig } from '@critical-path/core';
|
|
1
|
+
import { CriticalPathEngine, type AuthorType, type CriticalPathConfig } from '@critical-path/core';
|
|
2
|
+
|
|
3
|
+
const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
4
|
+
const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-request identity resolved by `getContext`. `userId` becomes the actor every mutation in
|
|
8
|
+
* the request is attributed to. Extra fields (e.g. `tenantId`, `roles`) are carried for
|
|
9
|
+
* future authorization hooks.
|
|
10
|
+
*/
|
|
11
|
+
export interface RequestContext {
|
|
12
|
+
userId?: string;
|
|
13
|
+
userName?: string;
|
|
14
|
+
actorType?: AuthorType;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RequestInitOptions {
|
|
19
|
+
getContext?: CriticalPathRouterOptions['getContext'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CorsOptions {
|
|
23
|
+
/** Allowed origins. `'*'` allows any origin (and cannot be combined with `credentials`). */
|
|
24
|
+
origins: '*' | string[];
|
|
25
|
+
/** Send `Access-Control-Allow-Credentials: true` for allowed origins. */
|
|
26
|
+
credentials?: boolean;
|
|
27
|
+
/** Request headers browsers may send. Defaults to `Content-Type` and `Authorization`. */
|
|
28
|
+
allowHeaders?: string[];
|
|
29
|
+
/** Seconds browsers may cache preflight results. */
|
|
30
|
+
maxAge?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class BadRequestError extends Error {
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'BadRequestError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CriticalPathRouterOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Called for unexpected errors (those that would produce a 500), e.g. to report to Sentry.
|
|
43
|
+
* Return a `Response` to replace the default 500 response. When omitted, errors are logged
|
|
44
|
+
* with `console.error`.
|
|
45
|
+
*/
|
|
46
|
+
onError?: (error: unknown, request: Request) => void | Response | Promise<void | Response>;
|
|
47
|
+
/**
|
|
48
|
+
* Include the real error message in 500 responses. Defaults to `true` only when
|
|
49
|
+
* `NODE_ENV === 'development'`, so production deployments never leak internals.
|
|
50
|
+
*/
|
|
51
|
+
exposeErrors?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Resolves the caller's identity from the request (session cookie, bearer token, etc.).
|
|
54
|
+
* When it returns a `userId`, all mutations in the request are attributed to that user and
|
|
55
|
+
* identity fields in request bodies (`actorId`, `authorId`, `userId`, ...) are ignored.
|
|
56
|
+
*/
|
|
57
|
+
getContext?: (request: Request) => RequestContext | null | undefined | Promise<RequestContext | null | undefined>;
|
|
58
|
+
/** Reject requests with `401` unless `getContext` returns a `userId`. Defaults to `false`. */
|
|
59
|
+
requireAuth?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The path the router is mounted at, e.g. `/api/pm`. Requests outside it return `404`.
|
|
62
|
+
* When omitted, everything up to the first `/critical-path` segment is stripped.
|
|
63
|
+
*/
|
|
64
|
+
basePath?: string;
|
|
65
|
+
/** CORS policy. Defaults to `{ origins: '*' }`; pass `false` to send no CORS headers. */
|
|
66
|
+
cors?: CorsOptions | false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDevelopment(): boolean {
|
|
70
|
+
return (globalThis as any).process?.env?.NODE_ENV === 'development';
|
|
71
|
+
}
|
|
2
72
|
|
|
3
73
|
export class CriticalPathRouter {
|
|
4
74
|
public engine: CriticalPathEngine;
|
|
75
|
+
private readonly onError?: CriticalPathRouterOptions['onError'];
|
|
76
|
+
private readonly exposeErrors: boolean;
|
|
77
|
+
private readonly getContext?: CriticalPathRouterOptions['getContext'];
|
|
78
|
+
private readonly requireAuth: boolean;
|
|
79
|
+
private readonly basePath?: string;
|
|
80
|
+
private readonly cors: CorsOptions | false;
|
|
5
81
|
|
|
6
|
-
constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine) {
|
|
82
|
+
constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine, options: CriticalPathRouterOptions = {}) {
|
|
7
83
|
if (configOrEngine instanceof CriticalPathEngine) {
|
|
8
84
|
this.engine = configOrEngine;
|
|
9
85
|
} else {
|
|
10
86
|
this.engine = new CriticalPathEngine(configOrEngine);
|
|
11
87
|
}
|
|
88
|
+
this.onError = options.onError;
|
|
89
|
+
this.exposeErrors = options.exposeErrors ?? isDevelopment();
|
|
90
|
+
this.getContext = options.getContext;
|
|
91
|
+
this.requireAuth = options.requireAuth ?? false;
|
|
92
|
+
this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
|
|
93
|
+
this.cors = options.cors === undefined ? { origins: '*' } : options.cors;
|
|
94
|
+
if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
|
|
95
|
+
throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handles a request. Adapters that resolve identity from something other than the `Request`
|
|
101
|
+
* (e.g. SvelteKit `locals`) pass `init.getContext`, which replaces the router's `getContext`
|
|
102
|
+
* for this request.
|
|
103
|
+
*/
|
|
104
|
+
async handleRequest(request: Request, init: RequestInitOptions = {}): Promise<Response> {
|
|
105
|
+
const response = await this.dispatch(request, init);
|
|
106
|
+
return this.applyCors(request, response);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Returns the path after the mount point, or `null` when the request is outside `basePath`. */
|
|
110
|
+
private resolveSubpath(pathname: string): string | null {
|
|
111
|
+
if (this.basePath) {
|
|
112
|
+
if (pathname !== this.basePath && !pathname.startsWith(this.basePath + '/')) return null;
|
|
113
|
+
return pathname.slice(this.basePath.length).replace(/^\/+/, '');
|
|
114
|
+
}
|
|
115
|
+
// Extract subpath after /critical-path/ or /api/critical-path/
|
|
116
|
+
return pathname.replace(/^.*?\/critical-path\/?/, '').replace(/^\/+/, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private applyCors(request: Request, response: Response): Response {
|
|
120
|
+
if (!this.cors) return response;
|
|
121
|
+
|
|
122
|
+
const origin = request.headers.get('Origin');
|
|
123
|
+
let allowOrigin: string | undefined;
|
|
124
|
+
if (this.cors.origins === '*') {
|
|
125
|
+
allowOrigin = '*';
|
|
126
|
+
} else if (origin && this.cors.origins.includes(origin)) {
|
|
127
|
+
allowOrigin = origin;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Copy so headers are mutable even if a hook returned a Response with immutable headers.
|
|
131
|
+
const result = new Response(response.body, response);
|
|
132
|
+
if (this.cors.origins !== '*') result.headers.append('Vary', 'Origin');
|
|
133
|
+
if (!allowOrigin) return result;
|
|
134
|
+
|
|
135
|
+
result.headers.set('Access-Control-Allow-Origin', allowOrigin);
|
|
136
|
+
result.headers.set('Access-Control-Allow-Methods', CORS_ALLOW_METHODS);
|
|
137
|
+
result.headers.set('Access-Control-Allow-Headers', (this.cors.allowHeaders ?? DEFAULT_CORS_ALLOW_HEADERS).join(', '));
|
|
138
|
+
if (this.cors.credentials) result.headers.set('Access-Control-Allow-Credentials', 'true');
|
|
139
|
+
if (request.method.toUpperCase() === 'OPTIONS' && this.cors.maxAge !== undefined) {
|
|
140
|
+
result.headers.set('Access-Control-Max-Age', String(this.cors.maxAge));
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
12
143
|
}
|
|
13
144
|
|
|
14
|
-
async
|
|
145
|
+
private async dispatch(request: Request, init: RequestInitOptions): Promise<Response> {
|
|
15
146
|
const url = new URL(request.url);
|
|
16
147
|
const pathname = url.pathname;
|
|
17
148
|
const method = request.method.toUpperCase();
|
|
18
149
|
|
|
19
|
-
|
|
20
|
-
|
|
150
|
+
const subpath = this.resolveSubpath(pathname);
|
|
151
|
+
if (subpath === null) {
|
|
152
|
+
return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
|
|
153
|
+
}
|
|
21
154
|
const segments = subpath.split('/').filter(Boolean);
|
|
22
155
|
|
|
156
|
+
// CORS preflight (browsers send it without credentials, so it is answered before auth)
|
|
157
|
+
if (method === 'OPTIONS') {
|
|
158
|
+
return new Response(null, { status: 204 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let context: RequestContext | null | undefined;
|
|
162
|
+
try {
|
|
163
|
+
const resolve = init.getContext ?? this.getContext;
|
|
164
|
+
context = resolve ? await resolve(request) : undefined;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return this.errorResponse(err, request);
|
|
167
|
+
}
|
|
168
|
+
if (this.requireAuth && !context?.userId) {
|
|
169
|
+
return this.jsonResponse({ error: 'Authentication required' }, 401);
|
|
170
|
+
}
|
|
171
|
+
const engine = context?.userId
|
|
172
|
+
? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
|
|
173
|
+
: this.engine;
|
|
174
|
+
|
|
23
175
|
try {
|
|
24
176
|
// Workflows API
|
|
25
177
|
if (segments[0] === 'workflows') {
|
|
26
178
|
const workflowId = segments[1];
|
|
27
179
|
if (!workflowId) {
|
|
28
180
|
if (method === 'GET') {
|
|
29
|
-
const workflows = await
|
|
181
|
+
const workflows = await engine.getWorkflows();
|
|
30
182
|
return this.jsonResponse({ workflows });
|
|
31
183
|
}
|
|
32
184
|
if (method === 'POST') {
|
|
33
|
-
const body = await
|
|
34
|
-
const workflow = await
|
|
185
|
+
const body = await this.readJson(request);
|
|
186
|
+
const workflow = await engine.createWorkflow(body);
|
|
35
187
|
return this.jsonResponse({ workflow }, 201);
|
|
36
188
|
}
|
|
37
189
|
} else {
|
|
38
190
|
if (method === 'GET') {
|
|
39
|
-
const workflow = await
|
|
191
|
+
const workflow = await engine.getWorkflow(workflowId);
|
|
40
192
|
if (!workflow) return this.jsonResponse({ error: 'Workflow not found' }, 404);
|
|
41
193
|
return this.jsonResponse({ workflow });
|
|
42
194
|
}
|
|
43
195
|
if (method === 'PATCH' || method === 'PUT') {
|
|
44
|
-
const body = await
|
|
45
|
-
const updated = await
|
|
196
|
+
const body = await this.readJson(request);
|
|
197
|
+
const updated = await engine.updateWorkflow(workflowId, body);
|
|
46
198
|
if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
|
|
47
199
|
return this.jsonResponse({ workflow: updated });
|
|
48
200
|
}
|
|
49
201
|
if (method === 'DELETE') {
|
|
50
|
-
const deleted = await
|
|
51
|
-
return this.jsonResponse({
|
|
202
|
+
const deleted = await engine.deleteWorkflow(workflowId);
|
|
203
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
204
|
+
return this.jsonResponse({ success: true });
|
|
52
205
|
}
|
|
53
206
|
}
|
|
54
207
|
}
|
|
@@ -59,17 +212,17 @@ export class CriticalPathRouter {
|
|
|
59
212
|
const subResource = segments[2];
|
|
60
213
|
if (!projectId) {
|
|
61
214
|
if (method === 'GET') {
|
|
62
|
-
const projects = await
|
|
215
|
+
const projects = await engine.getProjects();
|
|
63
216
|
return this.jsonResponse({ projects });
|
|
64
217
|
}
|
|
65
218
|
if (method === 'POST') {
|
|
66
|
-
const body = await
|
|
67
|
-
const project = await
|
|
219
|
+
const body = await this.readJson(request);
|
|
220
|
+
const project = await engine.createProject(body);
|
|
68
221
|
return this.jsonResponse({ project }, 201);
|
|
69
222
|
}
|
|
70
223
|
} else if (subResource === 'critical-path') {
|
|
71
224
|
if (method === 'GET') {
|
|
72
|
-
const analysis = await
|
|
225
|
+
const analysis = await engine.calculateCriticalPath(projectId);
|
|
73
226
|
return this.jsonResponse({ analysis });
|
|
74
227
|
}
|
|
75
228
|
} else if (subResource === 'ladder' || subResource === 'timeline-ladder') {
|
|
@@ -77,7 +230,7 @@ export class CriticalPathRouter {
|
|
|
77
230
|
const level = (url.searchParams.get('level') || 'all') as any;
|
|
78
231
|
const containerId = url.searchParams.get('containerId') || undefined;
|
|
79
232
|
const iterationId = url.searchParams.get('iterationId') || undefined;
|
|
80
|
-
const ladder = await
|
|
233
|
+
const ladder = await engine.getTimelineLadder(projectId, { level, containerId, iterationId });
|
|
81
234
|
return this.jsonResponse({ ladder });
|
|
82
235
|
}
|
|
83
236
|
} else if (subResource === 'workload' || subResource === 'workload-distribution') {
|
|
@@ -91,7 +244,7 @@ export class CriticalPathRouter {
|
|
|
91
244
|
? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
|
|
92
245
|
: undefined;
|
|
93
246
|
|
|
94
|
-
const workload = await
|
|
247
|
+
const workload = await engine.getWorkloadDistribution(projectId, {
|
|
95
248
|
startDate,
|
|
96
249
|
endDate,
|
|
97
250
|
interval,
|
|
@@ -103,13 +256,20 @@ export class CriticalPathRouter {
|
|
|
103
256
|
}
|
|
104
257
|
} else {
|
|
105
258
|
if (method === 'GET') {
|
|
106
|
-
const project = await
|
|
259
|
+
const project = await engine.getProject(projectId);
|
|
260
|
+
if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
|
|
261
|
+
return this.jsonResponse({ project });
|
|
262
|
+
}
|
|
263
|
+
if (method === 'PATCH' || method === 'PUT') {
|
|
264
|
+
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...updates } = await this.readJson(request);
|
|
265
|
+
const project = await engine.updateProject(projectId, updates);
|
|
107
266
|
if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
|
|
108
267
|
return this.jsonResponse({ project });
|
|
109
268
|
}
|
|
110
269
|
if (method === 'DELETE') {
|
|
111
|
-
const deleted = await
|
|
112
|
-
return this.jsonResponse({
|
|
270
|
+
const deleted = await engine.deleteProject(projectId);
|
|
271
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
272
|
+
return this.jsonResponse({ success: true });
|
|
113
273
|
}
|
|
114
274
|
}
|
|
115
275
|
}
|
|
@@ -122,42 +282,45 @@ export class CriticalPathRouter {
|
|
|
122
282
|
if (!taskId) {
|
|
123
283
|
if (method === 'GET') {
|
|
124
284
|
const projectId = url.searchParams.get('projectId') || undefined;
|
|
125
|
-
const tasks = await
|
|
285
|
+
const tasks = await engine.getTasks(projectId);
|
|
126
286
|
return this.jsonResponse({ tasks });
|
|
127
287
|
}
|
|
128
288
|
if (method === 'POST') {
|
|
129
|
-
const body = await
|
|
130
|
-
const task = await
|
|
289
|
+
const body = await this.readJson(request);
|
|
290
|
+
const task = await engine.createTask(body);
|
|
131
291
|
return this.jsonResponse({ task }, 201);
|
|
132
292
|
}
|
|
133
293
|
} else if (subResource === 'comments') {
|
|
134
294
|
if (method === 'GET') {
|
|
135
|
-
const comments = await
|
|
295
|
+
const comments = await engine.getComments(taskId);
|
|
136
296
|
return this.jsonResponse({ comments });
|
|
137
297
|
}
|
|
138
298
|
if (method === 'POST') {
|
|
139
|
-
const body = await
|
|
140
|
-
const comment = await
|
|
299
|
+
const body = await this.readJson(request);
|
|
300
|
+
const comment = await engine.addComment({ ...body, taskId });
|
|
141
301
|
return this.jsonResponse({ comment }, 201);
|
|
142
302
|
}
|
|
143
303
|
} else if (subResource === 'attachments') {
|
|
144
304
|
if (method === 'GET') {
|
|
145
|
-
const attachments = await
|
|
305
|
+
const attachments = await engine.getAttachments({ taskId });
|
|
146
306
|
return this.jsonResponse({ attachments });
|
|
147
307
|
}
|
|
148
308
|
if (method === 'POST') {
|
|
149
|
-
const body = await
|
|
150
|
-
const attachment = await
|
|
309
|
+
const body = await this.readJson(request);
|
|
310
|
+
const attachment = await engine.createAttachment({ ...body, taskId });
|
|
151
311
|
return this.jsonResponse({ attachment }, 201);
|
|
152
312
|
}
|
|
153
313
|
} else if (subResource === 'dependencies') {
|
|
154
314
|
if (method === 'GET') {
|
|
155
|
-
const graph = await
|
|
315
|
+
const graph = await engine.getTaskDependencyGraph(taskId);
|
|
156
316
|
return this.jsonResponse({ graph });
|
|
157
317
|
}
|
|
158
318
|
if (method === 'POST') {
|
|
159
|
-
const body = await
|
|
160
|
-
|
|
319
|
+
const body = await this.readJson(request);
|
|
320
|
+
if (typeof body.dependsOnTaskId !== 'string' || !body.dependsOnTaskId) {
|
|
321
|
+
return this.jsonResponse({ error: 'dependsOnTaskId is required' }, 400);
|
|
322
|
+
}
|
|
323
|
+
const dep = await engine.addDependency({
|
|
161
324
|
taskId,
|
|
162
325
|
dependsOnTaskId: body.dependsOnTaskId,
|
|
163
326
|
type: body.type || 'blocking'
|
|
@@ -166,49 +329,50 @@ export class CriticalPathRouter {
|
|
|
166
329
|
}
|
|
167
330
|
} else if (subResource === 'state' || subResource === 'lifecycle') {
|
|
168
331
|
if (method === 'GET') {
|
|
169
|
-
const state = await
|
|
332
|
+
const state = await engine.getTaskLifecycleState(taskId);
|
|
170
333
|
if (!state) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
171
334
|
return this.jsonResponse({ state });
|
|
172
335
|
}
|
|
173
336
|
} else if (subResource === 'transitions' || subResource === 'allowed-transitions') {
|
|
174
337
|
if (method === 'GET') {
|
|
175
|
-
const allowedNextStatuses = await
|
|
176
|
-
const allowedPreviousStatuses = await
|
|
338
|
+
const allowedNextStatuses = await engine.getAllowedTaskTransitions(taskId);
|
|
339
|
+
const allowedPreviousStatuses = await engine.getAllowedPreviousTaskTransitions(taskId);
|
|
177
340
|
return this.jsonResponse({ allowedNextStatuses, allowedPreviousStatuses });
|
|
178
341
|
}
|
|
179
342
|
} else if (subResource === 'ladder') {
|
|
180
343
|
if (method === 'GET') {
|
|
181
|
-
const taskLadder = await
|
|
344
|
+
const taskLadder = await engine.getTaskLadder(taskId);
|
|
182
345
|
if (!taskLadder) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
183
346
|
return this.jsonResponse({ taskLadder });
|
|
184
347
|
}
|
|
185
348
|
} else if (subResource === 'metrics') {
|
|
186
349
|
if (method === 'GET') {
|
|
187
|
-
const metrics = await
|
|
350
|
+
const metrics = await engine.getTaskMetrics(taskId);
|
|
188
351
|
if (!metrics) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
189
352
|
return this.jsonResponse({ metrics });
|
|
190
353
|
}
|
|
191
354
|
} else if (subResource === 'progress-history') {
|
|
192
355
|
if (method === 'GET') {
|
|
193
|
-
const progressHistory = await
|
|
356
|
+
const progressHistory = await engine.getTaskProgressHistory(taskId);
|
|
194
357
|
if (!progressHistory) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
195
358
|
return this.jsonResponse({ progressHistory });
|
|
196
359
|
}
|
|
197
360
|
} else {
|
|
198
361
|
if (method === 'GET') {
|
|
199
|
-
const task = await
|
|
362
|
+
const task = await engine.getTask(taskId);
|
|
200
363
|
if (!task) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
201
364
|
return this.jsonResponse({ task });
|
|
202
365
|
}
|
|
203
366
|
if (method === 'PATCH' || method === 'PUT') {
|
|
204
|
-
const body = await
|
|
205
|
-
const updated = await
|
|
367
|
+
const body = await this.readJson(request);
|
|
368
|
+
const updated = await engine.updateTask(taskId, body);
|
|
206
369
|
if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
|
|
207
370
|
return this.jsonResponse({ task: updated });
|
|
208
371
|
}
|
|
209
372
|
if (method === 'DELETE') {
|
|
210
|
-
const deleted = await
|
|
211
|
-
return this.jsonResponse({
|
|
373
|
+
const deleted = await engine.deleteTask(taskId);
|
|
374
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
375
|
+
return this.jsonResponse({ success: true });
|
|
212
376
|
}
|
|
213
377
|
}
|
|
214
378
|
}
|
|
@@ -218,29 +382,30 @@ export class CriticalPathRouter {
|
|
|
218
382
|
const teamId = segments[1];
|
|
219
383
|
if (!teamId) {
|
|
220
384
|
if (method === 'GET') {
|
|
221
|
-
const teams = await
|
|
385
|
+
const teams = await engine.getTeams();
|
|
222
386
|
return this.jsonResponse({ teams });
|
|
223
387
|
}
|
|
224
388
|
if (method === 'POST') {
|
|
225
|
-
const body = await
|
|
226
|
-
const team = await
|
|
389
|
+
const body = await this.readJson(request);
|
|
390
|
+
const team = await engine.createTeam(body);
|
|
227
391
|
return this.jsonResponse({ team }, 201);
|
|
228
392
|
}
|
|
229
393
|
} else {
|
|
230
394
|
if (method === 'GET') {
|
|
231
|
-
const team = await
|
|
395
|
+
const team = await engine.getTeam(teamId);
|
|
232
396
|
if (!team) return this.jsonResponse({ error: 'Team not found' }, 404);
|
|
233
397
|
return this.jsonResponse({ team });
|
|
234
398
|
}
|
|
235
399
|
if (method === 'PATCH' || method === 'PUT') {
|
|
236
|
-
const body = await
|
|
237
|
-
const updated = await
|
|
400
|
+
const body = await this.readJson(request);
|
|
401
|
+
const updated = await engine.updateTeam(teamId, body);
|
|
238
402
|
if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
|
|
239
403
|
return this.jsonResponse({ team: updated });
|
|
240
404
|
}
|
|
241
405
|
if (method === 'DELETE') {
|
|
242
|
-
const deleted = await
|
|
243
|
-
return this.jsonResponse({
|
|
406
|
+
const deleted = await engine.deleteTeam(teamId);
|
|
407
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
408
|
+
return this.jsonResponse({ success: true });
|
|
244
409
|
}
|
|
245
410
|
}
|
|
246
411
|
}
|
|
@@ -252,29 +417,30 @@ export class CriticalPathRouter {
|
|
|
252
417
|
if (method === 'GET') {
|
|
253
418
|
const projectId = url.searchParams.get('projectId');
|
|
254
419
|
if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
|
|
255
|
-
const containers = await
|
|
420
|
+
const containers = await engine.getContainers(projectId);
|
|
256
421
|
return this.jsonResponse({ containers });
|
|
257
422
|
}
|
|
258
423
|
if (method === 'POST') {
|
|
259
|
-
const body = await
|
|
260
|
-
const container = await
|
|
424
|
+
const body = await this.readJson(request);
|
|
425
|
+
const container = await engine.createContainer(body);
|
|
261
426
|
return this.jsonResponse({ container }, 201);
|
|
262
427
|
}
|
|
263
428
|
} else {
|
|
264
429
|
if (method === 'GET') {
|
|
265
|
-
const container = await
|
|
430
|
+
const container = await engine.getContainer(containerId);
|
|
266
431
|
if (!container) return this.jsonResponse({ error: 'Container not found' }, 404);
|
|
267
432
|
return this.jsonResponse({ container });
|
|
268
433
|
}
|
|
269
434
|
if (method === 'PATCH' || method === 'PUT') {
|
|
270
|
-
const body = await
|
|
271
|
-
const updated = await
|
|
435
|
+
const body = await this.readJson(request);
|
|
436
|
+
const updated = await engine.updateContainer(containerId, body);
|
|
272
437
|
if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
|
|
273
438
|
return this.jsonResponse({ container: updated });
|
|
274
439
|
}
|
|
275
440
|
if (method === 'DELETE') {
|
|
276
|
-
const deleted = await
|
|
277
|
-
return this.jsonResponse({
|
|
441
|
+
const deleted = await engine.deleteContainer(containerId);
|
|
442
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
443
|
+
return this.jsonResponse({ success: true });
|
|
278
444
|
}
|
|
279
445
|
}
|
|
280
446
|
}
|
|
@@ -286,35 +452,36 @@ export class CriticalPathRouter {
|
|
|
286
452
|
if (method === 'GET') {
|
|
287
453
|
const projectId = url.searchParams.get('projectId');
|
|
288
454
|
if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
|
|
289
|
-
const deliverables = await
|
|
455
|
+
const deliverables = await engine.getDeliverables(projectId);
|
|
290
456
|
return this.jsonResponse({ deliverables });
|
|
291
457
|
}
|
|
292
458
|
if (method === 'POST') {
|
|
293
|
-
const body = await
|
|
294
|
-
const deliverable = await
|
|
459
|
+
const body = await this.readJson(request);
|
|
460
|
+
const deliverable = await engine.createDeliverable(body);
|
|
295
461
|
return this.jsonResponse({ deliverable }, 201);
|
|
296
462
|
}
|
|
297
463
|
} else if (segments[2] === 'summary') {
|
|
298
464
|
if (method === 'GET') {
|
|
299
|
-
const summary = await
|
|
465
|
+
const summary = await engine.getDeliverableSummary(deliverableId);
|
|
300
466
|
if (!summary) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
|
|
301
467
|
return this.jsonResponse({ summary });
|
|
302
468
|
}
|
|
303
469
|
} else {
|
|
304
470
|
if (method === 'GET') {
|
|
305
|
-
const deliverable = await
|
|
471
|
+
const deliverable = await engine.getDeliverable(deliverableId);
|
|
306
472
|
if (!deliverable) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
|
|
307
473
|
return this.jsonResponse({ deliverable });
|
|
308
474
|
}
|
|
309
475
|
if (method === 'PATCH' || method === 'PUT') {
|
|
310
|
-
const body = await
|
|
311
|
-
const updated = await
|
|
476
|
+
const body = await this.readJson(request);
|
|
477
|
+
const updated = await engine.updateDeliverable(deliverableId, body);
|
|
312
478
|
if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
|
|
313
479
|
return this.jsonResponse({ deliverable: updated });
|
|
314
480
|
}
|
|
315
481
|
if (method === 'DELETE') {
|
|
316
|
-
const deleted = await
|
|
317
|
-
return this.jsonResponse({
|
|
482
|
+
const deleted = await engine.deleteDeliverable(deliverableId);
|
|
483
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
484
|
+
return this.jsonResponse({ success: true });
|
|
318
485
|
}
|
|
319
486
|
}
|
|
320
487
|
}
|
|
@@ -326,29 +493,30 @@ export class CriticalPathRouter {
|
|
|
326
493
|
if (method === 'GET') {
|
|
327
494
|
const projectId = url.searchParams.get('projectId');
|
|
328
495
|
if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
|
|
329
|
-
const iterations = await
|
|
496
|
+
const iterations = await engine.getIterations(projectId);
|
|
330
497
|
return this.jsonResponse({ iterations });
|
|
331
498
|
}
|
|
332
499
|
if (method === 'POST') {
|
|
333
|
-
const body = await
|
|
334
|
-
const iteration = await
|
|
500
|
+
const body = await this.readJson(request);
|
|
501
|
+
const iteration = await engine.createIteration(body);
|
|
335
502
|
return this.jsonResponse({ iteration }, 201);
|
|
336
503
|
}
|
|
337
504
|
} else {
|
|
338
505
|
if (method === 'GET') {
|
|
339
|
-
const iteration = await
|
|
506
|
+
const iteration = await engine.getIteration(iterationId);
|
|
340
507
|
if (!iteration) return this.jsonResponse({ error: 'Iteration not found' }, 404);
|
|
341
508
|
return this.jsonResponse({ iteration });
|
|
342
509
|
}
|
|
343
510
|
if (method === 'PATCH' || method === 'PUT') {
|
|
344
|
-
const body = await
|
|
345
|
-
const updated = await
|
|
511
|
+
const body = await this.readJson(request);
|
|
512
|
+
const updated = await engine.updateIteration(iterationId, body);
|
|
346
513
|
if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
|
|
347
514
|
return this.jsonResponse({ iteration: updated });
|
|
348
515
|
}
|
|
349
516
|
if (method === 'DELETE') {
|
|
350
|
-
const deleted = await
|
|
351
|
-
return this.jsonResponse({
|
|
517
|
+
const deleted = await engine.deleteIteration(iterationId);
|
|
518
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
519
|
+
return this.jsonResponse({ success: true });
|
|
352
520
|
}
|
|
353
521
|
}
|
|
354
522
|
}
|
|
@@ -358,7 +526,7 @@ export class CriticalPathRouter {
|
|
|
358
526
|
if (method === 'GET') {
|
|
359
527
|
const projectId = url.searchParams.get('projectId') || undefined;
|
|
360
528
|
const taskId = url.searchParams.get('taskId') || undefined;
|
|
361
|
-
const activities = await
|
|
529
|
+
const activities = await engine.store.getActivities({ projectId, taskId });
|
|
362
530
|
return this.jsonResponse({ activities });
|
|
363
531
|
}
|
|
364
532
|
}
|
|
@@ -369,27 +537,28 @@ export class CriticalPathRouter {
|
|
|
369
537
|
const subResource = segments[2];
|
|
370
538
|
if (commentId && subResource === 'reactions') {
|
|
371
539
|
if (method === 'POST') {
|
|
372
|
-
const body = await
|
|
373
|
-
|
|
540
|
+
const body = await this.readJson(request);
|
|
541
|
+
const userId = context?.userId ?? body.userId;
|
|
542
|
+
if (!body.emoji || !userId) {
|
|
374
543
|
return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
|
|
375
544
|
}
|
|
376
|
-
const comment = await
|
|
545
|
+
const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
|
|
377
546
|
if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
378
547
|
return this.jsonResponse({ comment }, 200);
|
|
379
548
|
}
|
|
380
549
|
if (method === 'DELETE') {
|
|
381
550
|
let body: any = {};
|
|
382
551
|
try {
|
|
383
|
-
body = await
|
|
552
|
+
body = await this.readJson(request);
|
|
384
553
|
} catch {
|
|
385
554
|
// Body may be empty on DELETE, fallback to searchParams
|
|
386
555
|
}
|
|
387
556
|
const emoji = body.emoji || url.searchParams.get('emoji');
|
|
388
|
-
const userId = body.userId || url.searchParams.get('userId');
|
|
557
|
+
const userId = context?.userId ?? (body.userId || url.searchParams.get('userId'));
|
|
389
558
|
if (!emoji || !userId) {
|
|
390
559
|
return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
|
|
391
560
|
}
|
|
392
|
-
const comment = await
|
|
561
|
+
const comment = await engine.removeCommentReaction(commentId, { emoji, userId });
|
|
393
562
|
if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
394
563
|
return this.jsonResponse({ comment }, 200);
|
|
395
564
|
}
|
|
@@ -397,29 +566,30 @@ export class CriticalPathRouter {
|
|
|
397
566
|
if (method === 'GET') {
|
|
398
567
|
const taskId = url.searchParams.get('taskId');
|
|
399
568
|
if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
|
|
400
|
-
const comments = await
|
|
569
|
+
const comments = await engine.getComments(taskId);
|
|
401
570
|
return this.jsonResponse({ comments });
|
|
402
571
|
}
|
|
403
572
|
if (method === 'POST') {
|
|
404
|
-
const body = await
|
|
405
|
-
const comment = await
|
|
573
|
+
const body = await this.readJson(request);
|
|
574
|
+
const comment = await engine.addComment(body);
|
|
406
575
|
return this.jsonResponse({ comment }, 201);
|
|
407
576
|
}
|
|
408
577
|
} else {
|
|
409
578
|
if (method === 'GET') {
|
|
410
|
-
const comment = await
|
|
579
|
+
const comment = await engine.getComment(commentId);
|
|
411
580
|
if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
412
581
|
return this.jsonResponse({ comment });
|
|
413
582
|
}
|
|
414
583
|
if (method === 'PATCH' || method === 'PUT') {
|
|
415
|
-
const body = await
|
|
416
|
-
const updated = await
|
|
584
|
+
const body = await this.readJson(request);
|
|
585
|
+
const updated = await engine.updateComment(commentId, body);
|
|
417
586
|
if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
418
587
|
return this.jsonResponse({ comment: updated });
|
|
419
588
|
}
|
|
420
589
|
if (method === 'DELETE') {
|
|
421
|
-
const deleted = await
|
|
422
|
-
return this.jsonResponse({
|
|
590
|
+
const deleted = await engine.deleteComment(commentId);
|
|
591
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
592
|
+
return this.jsonResponse({ success: true });
|
|
423
593
|
}
|
|
424
594
|
}
|
|
425
595
|
}
|
|
@@ -429,14 +599,14 @@ export class CriticalPathRouter {
|
|
|
429
599
|
const attachmentId = segments[1];
|
|
430
600
|
if (attachmentId === 'presign') {
|
|
431
601
|
if (method === 'POST') {
|
|
432
|
-
const body = await
|
|
433
|
-
const presigned = await
|
|
602
|
+
const body = await this.readJson(request);
|
|
603
|
+
const presigned = await engine.getPresignedAttachmentUploadUrl(body);
|
|
434
604
|
return this.jsonResponse({ presigned });
|
|
435
605
|
}
|
|
436
606
|
} else if (attachmentId === 'upload') {
|
|
437
607
|
if (method === 'POST') {
|
|
438
|
-
const body = await
|
|
439
|
-
const attachment = await
|
|
608
|
+
const body = await this.readJson(request);
|
|
609
|
+
const attachment = await engine.uploadAttachmentFile(body);
|
|
440
610
|
return this.jsonResponse({ attachment }, 201);
|
|
441
611
|
}
|
|
442
612
|
} else if (!attachmentId) {
|
|
@@ -444,23 +614,24 @@ export class CriticalPathRouter {
|
|
|
444
614
|
const taskId = url.searchParams.get('taskId') || undefined;
|
|
445
615
|
const projectId = url.searchParams.get('projectId') || undefined;
|
|
446
616
|
const commentId = url.searchParams.get('commentId') || undefined;
|
|
447
|
-
const attachments = await
|
|
617
|
+
const attachments = await engine.getAttachments({ taskId, projectId, commentId });
|
|
448
618
|
return this.jsonResponse({ attachments });
|
|
449
619
|
}
|
|
450
620
|
if (method === 'POST') {
|
|
451
|
-
const body = await
|
|
452
|
-
const attachment = await
|
|
621
|
+
const body = await this.readJson(request);
|
|
622
|
+
const attachment = await engine.createAttachment(body);
|
|
453
623
|
return this.jsonResponse({ attachment }, 201);
|
|
454
624
|
}
|
|
455
625
|
} else {
|
|
456
626
|
if (method === 'GET') {
|
|
457
|
-
const attachment = await
|
|
627
|
+
const attachment = await engine.getAttachment(attachmentId);
|
|
458
628
|
if (!attachment) return this.jsonResponse({ error: 'Attachment not found' }, 404);
|
|
459
629
|
return this.jsonResponse({ attachment });
|
|
460
630
|
}
|
|
461
631
|
if (method === 'DELETE') {
|
|
462
|
-
const deleted = await
|
|
463
|
-
return this.jsonResponse({
|
|
632
|
+
const deleted = await engine.deleteAttachment(attachmentId);
|
|
633
|
+
if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
|
|
634
|
+
return this.jsonResponse({ success: true });
|
|
464
635
|
}
|
|
465
636
|
}
|
|
466
637
|
}
|
|
@@ -470,12 +641,12 @@ export class CriticalPathRouter {
|
|
|
470
641
|
if (method === 'GET') {
|
|
471
642
|
const taskId = url.searchParams.get('taskId');
|
|
472
643
|
if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
|
|
473
|
-
const entries = await
|
|
644
|
+
const entries = await engine.store.getTimeEntries(taskId);
|
|
474
645
|
return this.jsonResponse({ timeEntries: entries });
|
|
475
646
|
}
|
|
476
647
|
if (method === 'POST') {
|
|
477
|
-
const body = await
|
|
478
|
-
const entry = await
|
|
648
|
+
const body = await this.readJson(request);
|
|
649
|
+
const entry = await engine.logTime(body);
|
|
479
650
|
return this.jsonResponse({ timeEntry: entry }, 201);
|
|
480
651
|
}
|
|
481
652
|
}
|
|
@@ -493,7 +664,7 @@ export class CriticalPathRouter {
|
|
|
493
664
|
? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
|
|
494
665
|
: undefined;
|
|
495
666
|
|
|
496
|
-
const workload = await
|
|
667
|
+
const workload = await engine.getWorkloadDistribution(projectId, {
|
|
497
668
|
startDate,
|
|
498
669
|
endDate,
|
|
499
670
|
interval,
|
|
@@ -508,10 +679,10 @@ export class CriticalPathRouter {
|
|
|
508
679
|
// Agent Status / Telemetry API
|
|
509
680
|
if (segments[0] === 'status') {
|
|
510
681
|
if (method === 'POST') {
|
|
511
|
-
const body = await
|
|
682
|
+
const body = await this.readJson(request);
|
|
512
683
|
const status = typeof body.status === 'string' ? body.status : 'active';
|
|
513
|
-
if (
|
|
514
|
-
|
|
684
|
+
if (engine.events) {
|
|
685
|
+
engine.events.publish({
|
|
515
686
|
type: 'agent.status_updated' as any,
|
|
516
687
|
aggregateId: body.taskId || body.projectId || 'system',
|
|
517
688
|
payload: {
|
|
@@ -534,29 +705,59 @@ export class CriticalPathRouter {
|
|
|
534
705
|
|
|
535
706
|
return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
|
|
536
707
|
} catch (err: unknown) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
708
|
+
return this.errorResponse(err, request);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async readJson(request: Request): Promise<any> {
|
|
713
|
+
try {
|
|
714
|
+
return await request.json();
|
|
715
|
+
} catch {
|
|
716
|
+
throw new BadRequestError('Request body must be valid JSON.');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async errorResponse(err: unknown, request: Request): Promise<Response> {
|
|
721
|
+
const name = err && typeof err === 'object' && 'name' in err ? (err as Error).name : undefined;
|
|
722
|
+
const e = err as Record<string, any>;
|
|
723
|
+
|
|
724
|
+
switch (name) {
|
|
725
|
+
case 'BadRequestError':
|
|
726
|
+
case 'ValidationError':
|
|
727
|
+
case 'AttachmentValidationError':
|
|
728
|
+
return this.jsonResponse({ error: e.message }, 400);
|
|
729
|
+
case 'WorkflowValidationError':
|
|
730
|
+
return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
|
|
731
|
+
case 'CustomFieldValidationError':
|
|
732
|
+
return this.jsonResponse({ error: e.message, fieldKey: e.fieldKey }, 400);
|
|
733
|
+
case 'CircularDependencyError':
|
|
734
|
+
return this.jsonResponse({ error: e.message, cyclePath: e.cyclePath }, 409);
|
|
735
|
+
case 'NotFoundError':
|
|
736
|
+
return this.jsonResponse({ error: e.message }, 404);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Unexpected errors may carry internals (SQL, URLs, config details), so they are only
|
|
740
|
+
// exposed to callers when explicitly enabled.
|
|
741
|
+
if (this.onError) {
|
|
742
|
+
try {
|
|
743
|
+
const custom = await this.onError(err, request);
|
|
744
|
+
if (custom instanceof Response) return custom;
|
|
745
|
+
} catch (hookErr) {
|
|
746
|
+
console.error('[CriticalPathRouter] onError hook threw:', hookErr);
|
|
546
747
|
}
|
|
547
|
-
|
|
548
|
-
|
|
748
|
+
} else {
|
|
749
|
+
console.error('[CriticalPathRouter] Unhandled error:', err);
|
|
549
750
|
}
|
|
751
|
+
|
|
752
|
+
const message = this.exposeErrors && err instanceof Error ? err.message : 'Internal Server Error';
|
|
753
|
+
return this.jsonResponse({ error: message }, 500);
|
|
550
754
|
}
|
|
551
755
|
|
|
552
756
|
private jsonResponse(data: unknown, status = 200): Response {
|
|
553
757
|
return new Response(JSON.stringify(data), {
|
|
554
758
|
status,
|
|
555
759
|
headers: {
|
|
556
|
-
'Content-Type': 'application/json'
|
|
557
|
-
'Access-Control-Allow-Origin': '*',
|
|
558
|
-
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
559
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
|
760
|
+
'Content-Type': 'application/json'
|
|
560
761
|
}
|
|
561
762
|
});
|
|
562
763
|
}
|