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