@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/src/openapi.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { zodToJsonSchema } from 'zod-to-json-schema';
2
+ import * as schemas from '@critical-path/core/schemas';
3
+
4
+ type Method = 'get' | 'post' | 'patch' | 'delete';
5
+
6
+ interface RouteDoc {
7
+ method: Method;
8
+ path: string;
9
+ summary: string;
10
+ tag: string;
11
+ /** Query parameters; names ending in `!` are required. */
12
+ query?: string[];
13
+ body?: { safeParse(data: unknown): unknown };
14
+ /** Key of the JSON response envelope, e.g. `task` for `{ "task": { ... } }`. */
15
+ responseKey?: string;
16
+ status?: number;
17
+ }
18
+
19
+ /** Every route served by `CriticalPathRouter`. Kept in sync by the route drift test. */
20
+ export const ROUTES: RouteDoc[] = [
21
+ { method: 'get', path: '/workflows', summary: 'List workflows', tag: 'Workflows', responseKey: 'workflows' },
22
+ { method: 'post', path: '/workflows', summary: 'Create a workflow', tag: 'Workflows', body: schemas.CreateWorkflowSchema, responseKey: 'workflow', status: 201 },
23
+ { method: 'get', path: '/workflows/{workflowId}', summary: 'Get a workflow', tag: 'Workflows', responseKey: 'workflow' },
24
+ { method: 'patch', path: '/workflows/{workflowId}', summary: 'Update a workflow', tag: 'Workflows', body: schemas.UpdateWorkflowSchema, responseKey: 'workflow' },
25
+ { method: 'delete', path: '/workflows/{workflowId}', summary: 'Delete a workflow', tag: 'Workflows' },
26
+
27
+ { method: 'get', path: '/projects', summary: 'List projects', tag: 'Projects', responseKey: 'projects' },
28
+ { method: 'post', path: '/projects', summary: 'Create a project', tag: 'Projects', body: schemas.CreateProjectSchema, responseKey: 'project', status: 201 },
29
+ { method: 'get', path: '/projects/{projectId}', summary: 'Get a project', tag: 'Projects', responseKey: 'project' },
30
+ { method: 'patch', path: '/projects/{projectId}', summary: 'Update a project', tag: 'Projects', body: schemas.UpdateProjectSchema, responseKey: 'project' },
31
+ { method: 'delete', path: '/projects/{projectId}', summary: 'Delete a project and its tasks', tag: 'Projects' },
32
+ { method: 'get', path: '/projects/{projectId}/critical-path', summary: 'Critical path (CPM) analysis', tag: 'Analysis', responseKey: 'analysis' },
33
+ { method: 'get', path: '/projects/{projectId}/ladder', summary: 'Timeline ladder of abstraction', tag: 'Analysis', query: ['level', 'containerId', 'iterationId'], responseKey: 'ladder' },
34
+ { method: 'get', path: '/projects/{projectId}/workload', summary: 'Project workload distribution', tag: 'Analysis', query: ['startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
35
+
36
+ { method: 'get', path: '/tasks', summary: 'List tasks', tag: 'Tasks', query: ['projectId'], responseKey: 'tasks' },
37
+ { method: 'post', path: '/tasks', summary: 'Create a task', tag: 'Tasks', body: schemas.CreateTaskSchema, responseKey: 'task', status: 201 },
38
+ { method: 'get', path: '/tasks/{taskId}', summary: 'Get a task', tag: 'Tasks', responseKey: 'task' },
39
+ { method: 'patch', path: '/tasks/{taskId}', summary: 'Update a task (enforces workflow transitions)', tag: 'Tasks', body: schemas.UpdateTaskSchema, responseKey: 'task' },
40
+ { method: 'delete', path: '/tasks/{taskId}', summary: 'Delete a task', tag: 'Tasks' },
41
+ { method: 'get', path: '/tasks/{taskId}/comments', summary: 'List task comments', tag: 'Comments', responseKey: 'comments' },
42
+ { method: 'post', path: '/tasks/{taskId}/comments', summary: 'Comment on a task', tag: 'Comments', body: schemas.CreateCommentSchema.omit({ taskId: true }), responseKey: 'comment', status: 201 },
43
+ { method: 'get', path: '/tasks/{taskId}/attachments', summary: 'List task attachments', tag: 'Attachments', responseKey: 'attachments' },
44
+ { method: 'post', path: '/tasks/{taskId}/attachments', summary: 'Attach a file link to a task', tag: 'Attachments', body: schemas.CreateAttachmentSchema.omit({ taskId: true }), responseKey: 'attachment', status: 201 },
45
+ { method: 'get', path: '/tasks/{taskId}/dependencies', summary: 'Task dependency graph', tag: 'Dependencies', responseKey: 'graph' },
46
+ { method: 'post', path: '/tasks/{taskId}/dependencies', summary: 'Add a dependency (409 on cycles)', tag: 'Dependencies', body: schemas.CreateDependencySchema, responseKey: 'dependency', status: 201 },
47
+ { method: 'get', path: '/tasks/{taskId}/lifecycle', summary: 'Derived lifecycle state', tag: 'Tasks', responseKey: 'state' },
48
+ { method: 'get', path: '/tasks/{taskId}/transitions', summary: 'Allowed status transitions', tag: 'Tasks' },
49
+ { method: 'get', path: '/tasks/{taskId}/ladder', summary: 'Single-task ladder view', tag: 'Analysis', responseKey: 'taskLadder' },
50
+ { method: 'get', path: '/tasks/{taskId}/metrics', summary: 'Task metrics and EVM', tag: 'Analysis', responseKey: 'metrics' },
51
+ { method: 'get', path: '/tasks/{taskId}/progress-history', summary: 'Reconstructed progress history', tag: 'Analysis', responseKey: 'progressHistory' },
52
+
53
+ { method: 'get', path: '/teams', summary: 'List teams', tag: 'Teams', responseKey: 'teams' },
54
+ { method: 'post', path: '/teams', summary: 'Create a team', tag: 'Teams', body: schemas.CreateTeamSchema, responseKey: 'team', status: 201 },
55
+ { method: 'get', path: '/teams/{teamId}', summary: 'Get a team', tag: 'Teams', responseKey: 'team' },
56
+ { method: 'patch', path: '/teams/{teamId}', summary: 'Update a team', tag: 'Teams', body: schemas.UpdateTeamSchema, responseKey: 'team' },
57
+ { method: 'delete', path: '/teams/{teamId}', summary: 'Delete a team', tag: 'Teams' },
58
+
59
+ { method: 'get', path: '/containers', summary: 'List containers', tag: 'Containers', query: ['projectId!'], responseKey: 'containers' },
60
+ { method: 'post', path: '/containers', summary: 'Create a container', tag: 'Containers', body: schemas.CreateContainerSchema, responseKey: 'container', status: 201 },
61
+ { method: 'get', path: '/containers/{containerId}', summary: 'Get a container', tag: 'Containers', responseKey: 'container' },
62
+ { method: 'patch', path: '/containers/{containerId}', summary: 'Update a container', tag: 'Containers', body: schemas.UpdateContainerSchema, responseKey: 'container' },
63
+ { method: 'delete', path: '/containers/{containerId}', summary: 'Delete a container', tag: 'Containers' },
64
+
65
+ { method: 'get', path: '/deliverables', summary: 'List deliverables', tag: 'Deliverables', query: ['projectId!'], responseKey: 'deliverables' },
66
+ { method: 'post', path: '/deliverables', summary: 'Create a deliverable', tag: 'Deliverables', body: schemas.CreateDeliverableSchema, responseKey: 'deliverable', status: 201 },
67
+ { method: 'get', path: '/deliverables/{deliverableId}', summary: 'Get a deliverable', tag: 'Deliverables', responseKey: 'deliverable' },
68
+ { method: 'get', path: '/deliverables/{deliverableId}/summary', summary: 'Deliverable rollup summary', tag: 'Deliverables', responseKey: 'summary' },
69
+ { method: 'patch', path: '/deliverables/{deliverableId}', summary: 'Update a deliverable', tag: 'Deliverables', body: schemas.UpdateDeliverableSchema, responseKey: 'deliverable' },
70
+ { method: 'delete', path: '/deliverables/{deliverableId}', summary: 'Delete a deliverable', tag: 'Deliverables' },
71
+
72
+ { method: 'get', path: '/iterations', summary: 'List iterations', tag: 'Iterations', query: ['projectId!'], responseKey: 'iterations' },
73
+ { method: 'post', path: '/iterations', summary: 'Create an iteration', tag: 'Iterations', body: schemas.CreateIterationSchema, responseKey: 'iteration', status: 201 },
74
+ { method: 'get', path: '/iterations/{iterationId}', summary: 'Get an iteration', tag: 'Iterations', responseKey: 'iteration' },
75
+ { method: 'patch', path: '/iterations/{iterationId}', summary: 'Update an iteration', tag: 'Iterations', body: schemas.UpdateIterationSchema, responseKey: 'iteration' },
76
+ { method: 'delete', path: '/iterations/{iterationId}', summary: 'Delete an iteration', tag: 'Iterations' },
77
+
78
+ { method: 'get', path: '/activities', summary: 'Activity (audit) stream', tag: 'Activity', query: ['projectId', 'taskId'], responseKey: 'activities' },
79
+
80
+ { method: 'get', path: '/comments', summary: 'List comments for a task', tag: 'Comments', query: ['taskId!'], responseKey: 'comments' },
81
+ { method: 'post', path: '/comments', summary: 'Create a comment', tag: 'Comments', body: schemas.CreateCommentSchema, responseKey: 'comment', status: 201 },
82
+ { method: 'get', path: '/comments/{commentId}', summary: 'Get a comment', tag: 'Comments', responseKey: 'comment' },
83
+ { method: 'patch', path: '/comments/{commentId}', summary: 'Update a comment', tag: 'Comments', body: schemas.UpdateCommentSchema, responseKey: 'comment' },
84
+ { method: 'delete', path: '/comments/{commentId}', summary: 'Delete a comment', tag: 'Comments' },
85
+ { method: 'post', path: '/comments/{commentId}/reactions', summary: 'Add a reaction', tag: 'Comments', body: schemas.CommentReactionSchema, responseKey: 'comment' },
86
+ { method: 'delete', path: '/comments/{commentId}/reactions', summary: 'Remove a reaction', tag: 'Comments', query: ['emoji', 'userId'], responseKey: 'comment' },
87
+
88
+ { method: 'get', path: '/attachments', summary: 'List attachments', tag: 'Attachments', query: ['taskId', 'projectId', 'commentId'], responseKey: 'attachments' },
89
+ { method: 'post', path: '/attachments', summary: 'Register an attachment', tag: 'Attachments', body: schemas.CreateAttachmentSchema, responseKey: 'attachment', status: 201 },
90
+ { method: 'post', path: '/attachments/upload', summary: 'Upload a file through the configured FileStorageAdapter', tag: 'Attachments', body: schemas.UploadAttachmentSchema, responseKey: 'attachment', status: 201 },
91
+ { method: 'post', path: '/attachments/presign', summary: 'Request a presigned upload URL', tag: 'Attachments', body: schemas.PresignAttachmentSchema, responseKey: 'presigned' },
92
+ { method: 'get', path: '/attachments/{attachmentId}', summary: 'Get an attachment', tag: 'Attachments', responseKey: 'attachment' },
93
+ { method: 'delete', path: '/attachments/{attachmentId}', summary: 'Delete an attachment', tag: 'Attachments' },
94
+
95
+ { method: 'get', path: '/time-entries', summary: 'List time entries for a task', tag: 'Time Tracking', query: ['taskId!'], responseKey: 'timeEntries' },
96
+ { method: 'post', path: '/time-entries', summary: 'Log time against a task', tag: 'Time Tracking', body: schemas.LogTimeSchema, responseKey: 'timeEntry', status: 201 },
97
+
98
+ { method: 'get', path: '/workload', summary: 'Workload distribution across projects', tag: 'Analysis', query: ['projectId', 'startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
99
+ { method: 'post', path: '/status', summary: 'Publish an agent status update', tag: 'Agents' }
100
+ ];
101
+
102
+ const errorResponse = {
103
+ description: 'Error',
104
+ content: {
105
+ 'application/json': {
106
+ schema: {
107
+ type: 'object',
108
+ properties: {
109
+ error: { type: 'string' },
110
+ issues: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, message: { type: 'string' } } } }
111
+ },
112
+ required: ['error']
113
+ }
114
+ }
115
+ }
116
+ };
117
+
118
+ function toJsonSchema(schema: unknown): Record<string, unknown> {
119
+ const { $schema: _ignored, ...json } = zodToJsonSchema(schema as any, { $refStrategy: 'none' }) as Record<string, unknown>;
120
+ return json;
121
+ }
122
+
123
+ export interface OpenApiOptions {
124
+ /** Public URL the API is served from, e.g. `https://app.example.com/api/critical-path`. */
125
+ serverUrl?: string;
126
+ title?: string;
127
+ version?: string;
128
+ }
129
+
130
+ /**
131
+ * Builds an OpenAPI 3.1 document for the router. Request bodies are generated from the same zod
132
+ * schemas the router validates with, so the contract cannot drift from enforcement.
133
+ */
134
+ export function buildOpenApiDocument(options: OpenApiOptions = {}): Record<string, unknown> {
135
+ const paths: Record<string, Record<string, unknown>> = {};
136
+
137
+ for (const route of ROUTES) {
138
+ const pathParams = [...route.path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
139
+ const parameters = [
140
+ ...pathParams.map((name) => ({ name, in: 'path', required: true, schema: { type: 'string' } })),
141
+ ...(route.query ?? []).map((q) => ({
142
+ name: q.replace(/!$/, ''),
143
+ in: 'query',
144
+ required: q.endsWith('!'),
145
+ schema: { type: 'string' }
146
+ }))
147
+ ];
148
+ const status = String(route.status ?? 200);
149
+ const successSchema = route.responseKey
150
+ ? { type: 'object', properties: { [route.responseKey]: {} }, required: [route.responseKey] }
151
+ : { type: 'object' };
152
+
153
+ paths[route.path] ??= {};
154
+ paths[route.path][route.method] = {
155
+ summary: route.summary,
156
+ tags: [route.tag],
157
+ ...(parameters.length ? { parameters } : {}),
158
+ ...(route.body
159
+ ? { requestBody: { required: true, content: { 'application/json': { schema: toJsonSchema(route.body) } } } }
160
+ : {}),
161
+ responses: {
162
+ [status]: { description: 'Success', content: { 'application/json': { schema: successSchema } } },
163
+ '400': errorResponse,
164
+ '401': errorResponse,
165
+ '404': errorResponse,
166
+ ...(route.path.endsWith('/dependencies') && route.method === 'post' ? { '409': errorResponse } : {}),
167
+ '500': errorResponse
168
+ }
169
+ };
170
+ }
171
+
172
+ return {
173
+ openapi: '3.1.0',
174
+ info: { title: options.title ?? 'Critical Path API', version: options.version ?? '1.0.0' },
175
+ ...(options.serverUrl ? { servers: [{ url: options.serverUrl }] } : {}),
176
+ paths
177
+ };
178
+ }