@critical-path/core 0.22.0 → 0.24.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 +40 -0
- package/README.md +21 -0
- package/dist/domain/errors.d.ts +7 -1
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +3 -4
- package/dist/domain/errors.js.map +1 -1
- package/dist/engine/actor.test.d.ts +2 -0
- package/dist/engine/actor.test.d.ts.map +1 -0
- package/dist/engine/actor.test.js +53 -0
- package/dist/engine/actor.test.js.map +1 -0
- package/dist/engine/index.d.ts +12 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +54 -20
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +1941 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +289 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/schemas.test.d.ts +2 -0
- package/dist/schemas/schemas.test.d.ts.map +1 -0
- package/dist/schemas/schemas.test.js +58 -0
- package/dist/schemas/schemas.test.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +10 -3
- package/src/domain/errors.ts +10 -1
- package/src/engine/actor.test.ts +62 -0
- package/src/engine/index.ts +56 -20
- package/src/schemas/index.ts +337 -0
- package/src/schemas/schemas.test.ts +111 -0
- package/src/types/index.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
Actor,
|
|
2
3
|
CriticalPathConfig,
|
|
3
4
|
Project,
|
|
4
5
|
Task,
|
|
@@ -98,6 +99,8 @@ export class CriticalPathEngine {
|
|
|
98
99
|
public readonly plugins: PluginRegistry;
|
|
99
100
|
public readonly events: DomainEventBus;
|
|
100
101
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
102
|
+
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
103
|
+
public readonly actor?: Actor;
|
|
101
104
|
|
|
102
105
|
constructor(config: CriticalPathConfig = {}) {
|
|
103
106
|
this.config = config;
|
|
@@ -120,6 +123,23 @@ export class CriticalPathEngine {
|
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Returns a view of this engine that attributes every mutation to `actor`: activity log
|
|
128
|
+
* entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
|
|
129
|
+
* Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
|
|
130
|
+
* someone else. The view shares the store, plugins and event bus, and does not modify this engine,
|
|
131
|
+
* so it is safe to create one per request.
|
|
132
|
+
*/
|
|
133
|
+
withActor(actor: Actor): CriticalPathEngine {
|
|
134
|
+
const scoped = Object.create(this) as CriticalPathEngine;
|
|
135
|
+
Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
|
|
136
|
+
return scoped;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private actorIdOr(fallback: string): string {
|
|
140
|
+
return this.actor?.userId ?? fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
124
144
|
if (data.workflows) {
|
|
125
145
|
for (const wf of data.workflows) {
|
|
@@ -182,7 +202,7 @@ export class CriticalPathEngine {
|
|
|
182
202
|
await this.events.publish(event);
|
|
183
203
|
|
|
184
204
|
await this.store.logActivity({
|
|
185
|
-
actorId: 'system',
|
|
205
|
+
actorId: this.actorIdOr('system'),
|
|
186
206
|
action: 'workflow.created',
|
|
187
207
|
details: { name: created.name }
|
|
188
208
|
});
|
|
@@ -208,7 +228,7 @@ export class CriticalPathEngine {
|
|
|
208
228
|
await this.events.publish(event);
|
|
209
229
|
|
|
210
230
|
await this.store.logActivity({
|
|
211
|
-
actorId: 'system',
|
|
231
|
+
actorId: this.actorIdOr('system'),
|
|
212
232
|
action: 'workflow.updated',
|
|
213
233
|
details: { name: updated.name }
|
|
214
234
|
});
|
|
@@ -235,7 +255,7 @@ export class CriticalPathEngine {
|
|
|
235
255
|
await this.events.publish(event);
|
|
236
256
|
|
|
237
257
|
await this.store.logActivity({
|
|
238
|
-
actorId: 'system',
|
|
258
|
+
actorId: this.actorIdOr('system'),
|
|
239
259
|
action: 'workflow.deleted',
|
|
240
260
|
details: { name: existing.name }
|
|
241
261
|
});
|
|
@@ -306,7 +326,7 @@ export class CriticalPathEngine {
|
|
|
306
326
|
|
|
307
327
|
await this.store.logActivity({
|
|
308
328
|
projectId: created.id,
|
|
309
|
-
actorId: project.ownerId || 'system',
|
|
329
|
+
actorId: this.actorIdOr(project.ownerId || 'system'),
|
|
310
330
|
action: 'project.created',
|
|
311
331
|
details: { name: created.name, key: created.key }
|
|
312
332
|
});
|
|
@@ -333,7 +353,7 @@ export class CriticalPathEngine {
|
|
|
333
353
|
|
|
334
354
|
await this.store.logActivity({
|
|
335
355
|
projectId: updated.id,
|
|
336
|
-
actorId: 'system',
|
|
356
|
+
actorId: this.actorIdOr('system'),
|
|
337
357
|
action: 'project.updated',
|
|
338
358
|
details: { name: updated.name }
|
|
339
359
|
});
|
|
@@ -373,7 +393,7 @@ export class CriticalPathEngine {
|
|
|
373
393
|
|
|
374
394
|
await this.store.logActivity({
|
|
375
395
|
projectId: id,
|
|
376
|
-
actorId: 'system',
|
|
396
|
+
actorId: this.actorIdOr('system'),
|
|
377
397
|
action: 'project.deleted',
|
|
378
398
|
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
379
399
|
});
|
|
@@ -392,6 +412,9 @@ export class CriticalPathEngine {
|
|
|
392
412
|
}
|
|
393
413
|
|
|
394
414
|
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
415
|
+
if (this.actor && !taskInput.reporterId) {
|
|
416
|
+
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
417
|
+
}
|
|
395
418
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
396
419
|
const projectId = processedInput.projectId || taskInput.projectId;
|
|
397
420
|
const project = await this.store.getProject(projectId);
|
|
@@ -477,7 +500,7 @@ export class CriticalPathEngine {
|
|
|
477
500
|
await this.store.logActivity({
|
|
478
501
|
projectId: created.projectId,
|
|
479
502
|
taskId: created.id,
|
|
480
|
-
actorId: created.reporterId || 'system',
|
|
503
|
+
actorId: this.actorIdOr(created.reporterId || 'system'),
|
|
481
504
|
action: 'task.created',
|
|
482
505
|
details: { title: created.title, status: created.status }
|
|
483
506
|
});
|
|
@@ -504,25 +527,30 @@ export class CriticalPathEngine {
|
|
|
504
527
|
const existing = await this.store.getTask(id);
|
|
505
528
|
if (!existing) return null;
|
|
506
529
|
|
|
530
|
+
// On a withActor view, the scoped actor replaces any identity claimed in the payload.
|
|
531
|
+
// Explicit options from trusted server-side code still take precedence.
|
|
532
|
+
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
533
|
+
const claimed = this.actor ? undefined : updates;
|
|
534
|
+
|
|
507
535
|
const actorId =
|
|
508
536
|
options?.actorId ||
|
|
509
|
-
|
|
537
|
+
claimed?.actorId ||
|
|
510
538
|
options?.actor?.userId ||
|
|
511
|
-
|
|
539
|
+
claimed?.actor?.userId ||
|
|
512
540
|
'system';
|
|
513
541
|
const actorName =
|
|
514
542
|
options?.actorName ||
|
|
515
|
-
|
|
543
|
+
claimed?.actorName ||
|
|
516
544
|
options?.actor?.username ||
|
|
517
|
-
|
|
545
|
+
claimed?.actor?.username ||
|
|
518
546
|
(actorId === 'system' ? 'System' : undefined);
|
|
519
547
|
const actorType =
|
|
520
548
|
options?.actorType ||
|
|
521
|
-
|
|
549
|
+
claimed?.actorType ||
|
|
522
550
|
options?.actor?.actorType ||
|
|
523
|
-
|
|
551
|
+
claimed?.actor?.actorType ||
|
|
524
552
|
(actorId === 'system' ? 'system' : 'user');
|
|
525
|
-
const actorObj = options?.actor ||
|
|
553
|
+
const actorObj = options?.actor || claimed?.actor || {
|
|
526
554
|
userId: actorId,
|
|
527
555
|
username: actorName,
|
|
528
556
|
actorType
|
|
@@ -903,7 +931,7 @@ export class CriticalPathEngine {
|
|
|
903
931
|
await this.store.logActivity({
|
|
904
932
|
projectId: downstream.projectId,
|
|
905
933
|
taskId: downstream.id,
|
|
906
|
-
actorId: 'system',
|
|
934
|
+
actorId: this.actorIdOr('system'),
|
|
907
935
|
action: 'task.unblocked',
|
|
908
936
|
details: { unblockedByTaskId: completedTask.id }
|
|
909
937
|
});
|
|
@@ -947,7 +975,7 @@ export class CriticalPathEngine {
|
|
|
947
975
|
await this.store.logActivity({
|
|
948
976
|
projectId: existing.projectId,
|
|
949
977
|
taskId: id,
|
|
950
|
-
actorId: 'system',
|
|
978
|
+
actorId: this.actorIdOr('system'),
|
|
951
979
|
action: 'task.deleted',
|
|
952
980
|
details: { title: existing.title }
|
|
953
981
|
});
|
|
@@ -1082,7 +1110,7 @@ export class CriticalPathEngine {
|
|
|
1082
1110
|
|
|
1083
1111
|
const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
|
|
1084
1112
|
...entry,
|
|
1085
|
-
userId: entry.userId || task?.assigneeId || 'system',
|
|
1113
|
+
userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
|
|
1086
1114
|
loggedAt: entry.loggedAt
|
|
1087
1115
|
};
|
|
1088
1116
|
|
|
@@ -1112,6 +1140,9 @@ export class CriticalPathEngine {
|
|
|
1112
1140
|
}
|
|
1113
1141
|
|
|
1114
1142
|
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
1143
|
+
if (this.actor) {
|
|
1144
|
+
comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
|
|
1145
|
+
}
|
|
1115
1146
|
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
1116
1147
|
const created = await this.store.addComment({ ...comment, mentions });
|
|
1117
1148
|
const now = new Date().toISOString();
|
|
@@ -1178,6 +1209,7 @@ export class CriticalPathEngine {
|
|
|
1178
1209
|
}
|
|
1179
1210
|
|
|
1180
1211
|
async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
|
|
1212
|
+
if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
|
|
1181
1213
|
const existing = await this.store.getComment(commentId);
|
|
1182
1214
|
if (!existing) return null;
|
|
1183
1215
|
|
|
@@ -1223,6 +1255,7 @@ export class CriticalPathEngine {
|
|
|
1223
1255
|
}
|
|
1224
1256
|
|
|
1225
1257
|
async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
|
|
1258
|
+
if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
|
|
1226
1259
|
const existing = await this.store.getComment(commentId);
|
|
1227
1260
|
if (!existing) return null;
|
|
1228
1261
|
|
|
@@ -1267,6 +1300,9 @@ export class CriticalPathEngine {
|
|
|
1267
1300
|
}
|
|
1268
1301
|
|
|
1269
1302
|
async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
|
|
1303
|
+
if (this.actor) {
|
|
1304
|
+
attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
|
|
1305
|
+
}
|
|
1270
1306
|
validateAttachmentUrl(attachment.url);
|
|
1271
1307
|
const created = await this.store.createAttachment(attachment);
|
|
1272
1308
|
const now = new Date().toISOString();
|
|
@@ -1498,7 +1534,7 @@ export class CriticalPathEngine {
|
|
|
1498
1534
|
|
|
1499
1535
|
await this.store.logActivity({
|
|
1500
1536
|
projectId: created.projectId,
|
|
1501
|
-
actorId: 'system',
|
|
1537
|
+
actorId: this.actorIdOr('system'),
|
|
1502
1538
|
action: 'deliverable.created',
|
|
1503
1539
|
details: { title: created.title, format: created.format }
|
|
1504
1540
|
});
|
|
@@ -1551,7 +1587,7 @@ export class CriticalPathEngine {
|
|
|
1551
1587
|
|
|
1552
1588
|
await this.store.logActivity({
|
|
1553
1589
|
projectId: updated.projectId,
|
|
1554
|
-
actorId: 'system',
|
|
1590
|
+
actorId: this.actorIdOr('system'),
|
|
1555
1591
|
action: 'deliverable.updated',
|
|
1556
1592
|
details: { title: updated.title, status: updated.status }
|
|
1557
1593
|
});
|
|
@@ -1579,7 +1615,7 @@ export class CriticalPathEngine {
|
|
|
1579
1615
|
|
|
1580
1616
|
await this.store.logActivity({
|
|
1581
1617
|
projectId: existing.projectId,
|
|
1582
|
-
actorId: 'system',
|
|
1618
|
+
actorId: this.actorIdOr('system'),
|
|
1583
1619
|
action: 'deliverable.deleted',
|
|
1584
1620
|
details: { title: existing.title }
|
|
1585
1621
|
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request payload schemas for Critical Path resources.
|
|
3
|
+
*
|
|
4
|
+
* Exposed from `@critical-path/core/schemas` (not the package root) so browser bundles of core do
|
|
5
|
+
* not pull in zod. Create schemas omit server-assigned fields (`id`, `createdAt`, `updatedAt`, task
|
|
6
|
+
* `key`); update schemas additionally make every field optional and omit the owning `projectId`.
|
|
7
|
+
* Unknown keys are stripped rather than rejected, so older clients that send whole objects keep
|
|
8
|
+
* working while protected fields are never written.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { ValidationError, type ValidationIssue } from '../domain/errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
|
|
15
|
+
* applied) or throwing a `ValidationError` whose `issues` list every problem.
|
|
16
|
+
*/
|
|
17
|
+
export function parsePayload<S extends z.ZodTypeAny>(schema: S, data: unknown): z.output<S> {
|
|
18
|
+
const result = schema.safeParse(data ?? {});
|
|
19
|
+
if (result.success) return result.data;
|
|
20
|
+
const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
|
|
21
|
+
path: issue.path.join('.'),
|
|
22
|
+
message: issue.message
|
|
23
|
+
}));
|
|
24
|
+
const summary = issues.map((i) => (i.path ? `${i.path}: ${i.message}` : i.message)).join('; ');
|
|
25
|
+
throw new ValidationError(`Invalid request body: ${summary}`, issues);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isoString = z.string();
|
|
29
|
+
const nonEmpty = z.string().min(1);
|
|
30
|
+
|
|
31
|
+
export const SemanticStatusSchema = z.enum(['not_started', 'in_progress', 'completed', 'canceled']);
|
|
32
|
+
export const AuthorTypeSchema = z.enum(['user', 'agent', 'system']);
|
|
33
|
+
|
|
34
|
+
export const StatusDefinitionSchema = z.object({
|
|
35
|
+
key: nonEmpty,
|
|
36
|
+
label: z.string(),
|
|
37
|
+
category: SemanticStatusSchema
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const WorkScheduleSchema = z.object({
|
|
41
|
+
id: z.string().optional(),
|
|
42
|
+
name: z.string().optional(),
|
|
43
|
+
timezone: z.string().optional(),
|
|
44
|
+
defaultHoursPerDay: z.number().nonnegative().optional(),
|
|
45
|
+
days: z.array(
|
|
46
|
+
z.object({
|
|
47
|
+
dayOfWeek: z.number().int().min(0).max(6),
|
|
48
|
+
isWorkingDay: z.boolean(),
|
|
49
|
+
hours: z.array(z.object({ start: z.string(), end: z.string() })).optional()
|
|
50
|
+
})
|
|
51
|
+
),
|
|
52
|
+
holidays: z
|
|
53
|
+
.array(z.object({ date: z.string(), name: z.string().optional(), halfDay: z.boolean().optional() }))
|
|
54
|
+
.optional()
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const CustomFieldDefinitionSchema = z.object({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
key: nonEmpty,
|
|
60
|
+
label: z.string(),
|
|
61
|
+
type: z.enum(['text', 'number', 'date', 'boolean', 'single_select', 'multi_select', 'user']),
|
|
62
|
+
options: z.array(z.string()).optional(),
|
|
63
|
+
required: z.boolean().optional(),
|
|
64
|
+
defaultValue: z.unknown().optional()
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const TaskTypeDefinitionSchema = z.object({
|
|
68
|
+
key: nonEmpty,
|
|
69
|
+
label: z.string(),
|
|
70
|
+
description: z.string().optional(),
|
|
71
|
+
icon: z.string().optional(),
|
|
72
|
+
defaultStatusKey: z.string().optional(),
|
|
73
|
+
workflowId: z.string().optional()
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const customFields = z.record(z.unknown());
|
|
77
|
+
const metadata = z.record(z.unknown());
|
|
78
|
+
|
|
79
|
+
// --- Workflows ---
|
|
80
|
+
|
|
81
|
+
export const CreateWorkflowSchema = z.object({
|
|
82
|
+
name: nonEmpty,
|
|
83
|
+
description: z.string().optional(),
|
|
84
|
+
statuses: z.array(StatusDefinitionSchema),
|
|
85
|
+
transitions: z
|
|
86
|
+
.array(
|
|
87
|
+
z.object({
|
|
88
|
+
id: z.string().optional(),
|
|
89
|
+
name: z.string().optional(),
|
|
90
|
+
fromStatusKey: z.string(),
|
|
91
|
+
toStatusKey: z.string()
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
.default([]),
|
|
95
|
+
taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
|
|
96
|
+
defaultStatusKey: z.string().optional(),
|
|
97
|
+
isDefault: z.boolean().optional()
|
|
98
|
+
});
|
|
99
|
+
export const UpdateWorkflowSchema = CreateWorkflowSchema.partial();
|
|
100
|
+
|
|
101
|
+
// --- Projects ---
|
|
102
|
+
|
|
103
|
+
export const CreateProjectSchema = z.object({
|
|
104
|
+
key: z.string().optional(),
|
|
105
|
+
name: nonEmpty,
|
|
106
|
+
description: z.string().optional(),
|
|
107
|
+
ownerId: z.string().optional(),
|
|
108
|
+
members: z.array(z.string()).optional(),
|
|
109
|
+
teamIds: z.array(z.string()).optional(),
|
|
110
|
+
workflowId: z.string().optional(),
|
|
111
|
+
taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
|
|
112
|
+
statusDefinitions: z.array(StatusDefinitionSchema).optional(),
|
|
113
|
+
priorityDefinitions: z
|
|
114
|
+
.array(z.object({ key: z.string(), label: z.string(), level: z.number().optional() }))
|
|
115
|
+
.optional(),
|
|
116
|
+
customFieldDefinitions: z.array(CustomFieldDefinitionSchema).optional(),
|
|
117
|
+
schedule: WorkScheduleSchema.optional(),
|
|
118
|
+
startDate: isoString.optional(),
|
|
119
|
+
targetEndDate: isoString.optional()
|
|
120
|
+
});
|
|
121
|
+
export const UpdateProjectSchema = CreateProjectSchema.partial();
|
|
122
|
+
|
|
123
|
+
// --- Tasks ---
|
|
124
|
+
|
|
125
|
+
const taskFields = {
|
|
126
|
+
title: nonEmpty,
|
|
127
|
+
description: z.string().optional(),
|
|
128
|
+
status: z.string().optional(),
|
|
129
|
+
semanticStatus: SemanticStatusSchema.optional(),
|
|
130
|
+
priority: z.string().optional(),
|
|
131
|
+
taskType: z.string().optional(),
|
|
132
|
+
assigneeId: z.string().optional(),
|
|
133
|
+
assignees: z
|
|
134
|
+
.array(
|
|
135
|
+
z.object({
|
|
136
|
+
id: z.string(),
|
|
137
|
+
name: z.string().optional(),
|
|
138
|
+
role: z.string().optional(),
|
|
139
|
+
type: z.enum(['user', 'agent', 'team']).optional(),
|
|
140
|
+
avatarUrl: z.string().optional()
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
.optional(),
|
|
144
|
+
reporterId: z.string().optional(),
|
|
145
|
+
reviewerId: z.string().optional(),
|
|
146
|
+
iterationId: z.string().optional(),
|
|
147
|
+
teamId: z.string().optional(),
|
|
148
|
+
containerId: z.string().optional(),
|
|
149
|
+
deliverableId: z.string().optional(),
|
|
150
|
+
plannedStartDate: isoString.optional(),
|
|
151
|
+
actualStartDate: isoString.optional(),
|
|
152
|
+
actualEndDate: isoString.optional(),
|
|
153
|
+
completedAt: isoString.optional(),
|
|
154
|
+
dueDate: isoString.optional(),
|
|
155
|
+
estimatedHours: z.number().nonnegative().optional(),
|
|
156
|
+
loggedHours: z.number().nonnegative().optional(),
|
|
157
|
+
actualHours: z.number().nonnegative().optional(),
|
|
158
|
+
billableHours: z.number().nonnegative().optional(),
|
|
159
|
+
estimatedDurationMinutes: z.number().nonnegative().optional(),
|
|
160
|
+
actualDurationMinutes: z.number().nonnegative().optional(),
|
|
161
|
+
billableDurationMinutes: z.number().nonnegative().optional(),
|
|
162
|
+
actualDurationSeconds: z.number().nonnegative().optional(),
|
|
163
|
+
inProgressSince: isoString.nullable().optional(),
|
|
164
|
+
blockedDurationSeconds: z.number().nonnegative().optional(),
|
|
165
|
+
blockedSince: isoString.nullable().optional(),
|
|
166
|
+
progress: z.number().min(0).max(100).optional(),
|
|
167
|
+
isBlocked: z.boolean().optional(),
|
|
168
|
+
blockedReason: z.string().nullable().optional(),
|
|
169
|
+
tags: z.array(z.string()).optional(),
|
|
170
|
+
todos: z
|
|
171
|
+
.array(
|
|
172
|
+
z.object({
|
|
173
|
+
id: z.string(),
|
|
174
|
+
title: z.string(),
|
|
175
|
+
completed: z.boolean(),
|
|
176
|
+
createdAt: isoString.optional(),
|
|
177
|
+
completedAt: isoString.optional()
|
|
178
|
+
})
|
|
179
|
+
)
|
|
180
|
+
.optional(),
|
|
181
|
+
customFields: customFields.optional(),
|
|
182
|
+
parentId: z.string().optional()
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const CreateTaskSchema = z.object({ projectId: nonEmpty, ...taskFields });
|
|
186
|
+
|
|
187
|
+
/** Actor fields accepted on task updates; ignored when the server resolves the caller. */
|
|
188
|
+
const actorClaimFields = {
|
|
189
|
+
actorId: z.string().optional(),
|
|
190
|
+
actorName: z.string().optional(),
|
|
191
|
+
actorType: z.string().optional(),
|
|
192
|
+
actor: z
|
|
193
|
+
.object({ userId: z.string(), username: z.string().optional(), actorType: z.string().optional() })
|
|
194
|
+
.optional()
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const UpdateTaskSchema = z.object(taskFields).partial().extend(actorClaimFields);
|
|
198
|
+
|
|
199
|
+
// --- Dependencies ---
|
|
200
|
+
|
|
201
|
+
export const CreateDependencySchema = z.object({
|
|
202
|
+
dependsOnTaskId: nonEmpty,
|
|
203
|
+
type: z.enum(['blocking', 'blocked_by', 'relates_to']).default('blocking')
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// --- Deliverables ---
|
|
207
|
+
|
|
208
|
+
const deliverableFields = {
|
|
209
|
+
title: nonEmpty,
|
|
210
|
+
description: z.string().optional(),
|
|
211
|
+
status: z.string().optional(),
|
|
212
|
+
format: z.string().optional(),
|
|
213
|
+
specs: z.record(z.unknown()).optional(),
|
|
214
|
+
leadId: z.string().optional(),
|
|
215
|
+
reviewerId: z.string().optional(),
|
|
216
|
+
dueDate: isoString.optional(),
|
|
217
|
+
deliveredAt: isoString.optional(),
|
|
218
|
+
outputUrls: z.array(z.string()).optional(),
|
|
219
|
+
customFields: customFields.optional()
|
|
220
|
+
};
|
|
221
|
+
export const CreateDeliverableSchema = z.object({ projectId: nonEmpty, ...deliverableFields });
|
|
222
|
+
export const UpdateDeliverableSchema = z.object(deliverableFields).partial();
|
|
223
|
+
|
|
224
|
+
// --- Teams ---
|
|
225
|
+
|
|
226
|
+
export const CreateTeamSchema = z.object({
|
|
227
|
+
name: nonEmpty,
|
|
228
|
+
description: z.string().optional(),
|
|
229
|
+
leaderId: z.string().optional(),
|
|
230
|
+
memberIds: z.array(z.string()).default([]),
|
|
231
|
+
weeklyCapacityHours: z.number().nonnegative().optional(),
|
|
232
|
+
schedule: WorkScheduleSchema.optional()
|
|
233
|
+
});
|
|
234
|
+
export const UpdateTeamSchema = CreateTeamSchema.partial();
|
|
235
|
+
|
|
236
|
+
// --- Containers ---
|
|
237
|
+
|
|
238
|
+
const containerFields = {
|
|
239
|
+
name: nonEmpty,
|
|
240
|
+
description: z.string().optional(),
|
|
241
|
+
parentId: z.string().optional(),
|
|
242
|
+
type: z.string().optional(),
|
|
243
|
+
color: z.string().optional()
|
|
244
|
+
};
|
|
245
|
+
export const CreateContainerSchema = z.object({ projectId: nonEmpty, ...containerFields });
|
|
246
|
+
export const UpdateContainerSchema = z.object(containerFields).partial();
|
|
247
|
+
|
|
248
|
+
// --- Iterations ---
|
|
249
|
+
|
|
250
|
+
const iterationFields = {
|
|
251
|
+
name: nonEmpty,
|
|
252
|
+
goal: z.string().optional(),
|
|
253
|
+
type: z.string().optional(),
|
|
254
|
+
startDate: isoString.optional(),
|
|
255
|
+
endDate: isoString.optional(),
|
|
256
|
+
status: z.enum(['planning', 'active', 'completed']).default('planning')
|
|
257
|
+
};
|
|
258
|
+
export const CreateIterationSchema = z.object({ projectId: nonEmpty, ...iterationFields });
|
|
259
|
+
export const UpdateIterationSchema = z
|
|
260
|
+
.object({ ...iterationFields, status: z.enum(['planning', 'active', 'completed']) })
|
|
261
|
+
.partial();
|
|
262
|
+
|
|
263
|
+
// --- Comments & Reactions ---
|
|
264
|
+
|
|
265
|
+
export const CreateCommentSchema = z.object({
|
|
266
|
+
taskId: nonEmpty,
|
|
267
|
+
/** Required unless the server resolves the caller, in which case it is replaced. */
|
|
268
|
+
authorId: z.string().optional(),
|
|
269
|
+
authorType: AuthorTypeSchema.optional(),
|
|
270
|
+
parentId: z.string().optional(),
|
|
271
|
+
content: nonEmpty,
|
|
272
|
+
mentions: z.array(z.string()).optional(),
|
|
273
|
+
metadata: metadata.optional()
|
|
274
|
+
});
|
|
275
|
+
export const UpdateCommentSchema = z
|
|
276
|
+
.object({ content: nonEmpty, mentions: z.array(z.string()), metadata })
|
|
277
|
+
.partial();
|
|
278
|
+
|
|
279
|
+
export const CommentReactionSchema = z.object({
|
|
280
|
+
emoji: nonEmpty,
|
|
281
|
+
/** Required unless the server resolves the caller, in which case it is replaced. */
|
|
282
|
+
userId: z.string().optional()
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- Attachments ---
|
|
286
|
+
|
|
287
|
+
const attachmentLinks = {
|
|
288
|
+
taskId: z.string().optional(),
|
|
289
|
+
projectId: z.string().optional(),
|
|
290
|
+
commentId: z.string().optional(),
|
|
291
|
+
/** Required unless the server resolves the caller, in which case it is replaced. */
|
|
292
|
+
uploaderId: z.string().optional(),
|
|
293
|
+
uploaderType: AuthorTypeSchema.optional(),
|
|
294
|
+
artifactType: z.enum(['plan', 'spec', 'deliverable', 'review', 'general']).optional(),
|
|
295
|
+
metadata: metadata.optional()
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export const CreateAttachmentSchema = z.object({
|
|
299
|
+
...attachmentLinks,
|
|
300
|
+
filename: nonEmpty,
|
|
301
|
+
// Linked (rather than uploaded) files often have no known type or size.
|
|
302
|
+
mimeType: nonEmpty.default('application/octet-stream'),
|
|
303
|
+
sizeBytes: z.number().int().nonnegative().default(0),
|
|
304
|
+
url: nonEmpty,
|
|
305
|
+
storageKey: z.string().optional()
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export const UploadAttachmentSchema = z.object({
|
|
309
|
+
...attachmentLinks,
|
|
310
|
+
filename: nonEmpty,
|
|
311
|
+
data: z.string(),
|
|
312
|
+
mimeType: z.string().optional(),
|
|
313
|
+
pathPrefix: z.string().optional(),
|
|
314
|
+
encoding: z.enum(['base64', 'utf-8', 'binary']).optional()
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export const PresignAttachmentSchema = z.object({
|
|
318
|
+
storageKey: nonEmpty,
|
|
319
|
+
expiresInSeconds: z.number().int().positive().optional(),
|
|
320
|
+
contentType: z.string().optional()
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// --- Time Tracking ---
|
|
324
|
+
|
|
325
|
+
export const LogTimeSchema = z.object({
|
|
326
|
+
taskId: nonEmpty,
|
|
327
|
+
hours: z.number().positive(),
|
|
328
|
+
isBillable: z.boolean().optional(),
|
|
329
|
+
description: z.string().optional(),
|
|
330
|
+
userId: z.string().optional(),
|
|
331
|
+
loggedAt: isoString.optional()
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
|
|
335
|
+
export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
|
|
336
|
+
export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
|
|
337
|
+
export type UpdateProjectPayload = z.infer<typeof UpdateProjectSchema>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type {
|
|
4
|
+
CreateTaskInput,
|
|
5
|
+
Task,
|
|
6
|
+
CreateProjectInput,
|
|
7
|
+
Project,
|
|
8
|
+
Workflow,
|
|
9
|
+
CreateDeliverableInput,
|
|
10
|
+
Deliverable,
|
|
11
|
+
Team,
|
|
12
|
+
TaskContainer,
|
|
13
|
+
Iteration,
|
|
14
|
+
Comment,
|
|
15
|
+
CreateAttachmentInput,
|
|
16
|
+
TimeEntry
|
|
17
|
+
} from '../types/index.js';
|
|
18
|
+
import {
|
|
19
|
+
CreateTaskSchema,
|
|
20
|
+
UpdateTaskSchema,
|
|
21
|
+
CreateProjectSchema,
|
|
22
|
+
UpdateProjectSchema,
|
|
23
|
+
CreateWorkflowSchema,
|
|
24
|
+
CreateDeliverableSchema,
|
|
25
|
+
UpdateDeliverableSchema,
|
|
26
|
+
CreateTeamSchema,
|
|
27
|
+
CreateContainerSchema,
|
|
28
|
+
UpdateContainerSchema,
|
|
29
|
+
CreateIterationSchema,
|
|
30
|
+
CreateCommentSchema,
|
|
31
|
+
UpdateCommentSchema,
|
|
32
|
+
CreateAttachmentSchema,
|
|
33
|
+
LogTimeSchema
|
|
34
|
+
} from './index.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compile-time check that a schema covers exactly the writable keys of a domain type. If a field
|
|
38
|
+
* is added to a type in `types/index.ts` but not to its schema, the build fails here instead of
|
|
39
|
+
* the field being silently stripped from API requests.
|
|
40
|
+
*/
|
|
41
|
+
type SameKeys<Schema, Expected> = [Exclude<keyof Schema, keyof Expected>, Exclude<keyof Expected, keyof Schema>] extends [
|
|
42
|
+
never,
|
|
43
|
+
never
|
|
44
|
+
]
|
|
45
|
+
? true
|
|
46
|
+
: { extraInSchema: Exclude<keyof Schema, keyof Expected>; missingFromSchema: Exclude<keyof Expected, keyof Schema> };
|
|
47
|
+
|
|
48
|
+
type Infer<S extends z.ZodTypeAny> = z.infer<S>;
|
|
49
|
+
type ServerAssigned = 'id' | 'createdAt' | 'updatedAt';
|
|
50
|
+
type ActorClaims = 'actorId' | 'actorName' | 'actorType' | 'actor';
|
|
51
|
+
|
|
52
|
+
const keyChecks: true[] = [
|
|
53
|
+
true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
|
|
54
|
+
true as SameKeys<Omit<Infer<typeof UpdateTaskSchema>, ActorClaims>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
|
|
55
|
+
// `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
|
|
56
|
+
true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
|
|
57
|
+
true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
|
|
58
|
+
true as SameKeys<Infer<typeof CreateWorkflowSchema>, Omit<Workflow, ServerAssigned>>,
|
|
59
|
+
true as SameKeys<Infer<typeof CreateDeliverableSchema>, CreateDeliverableInput>,
|
|
60
|
+
true as SameKeys<Infer<typeof UpdateDeliverableSchema>, Omit<Deliverable, ServerAssigned | 'projectId'>>,
|
|
61
|
+
true as SameKeys<Infer<typeof CreateTeamSchema>, Omit<Team, ServerAssigned>>,
|
|
62
|
+
true as SameKeys<Infer<typeof CreateContainerSchema>, Omit<TaskContainer, ServerAssigned>>,
|
|
63
|
+
true as SameKeys<Infer<typeof UpdateContainerSchema>, Omit<TaskContainer, ServerAssigned | 'projectId'>>,
|
|
64
|
+
true as SameKeys<Infer<typeof CreateIterationSchema>, Omit<Iteration, 'id' | 'createdAt'>>,
|
|
65
|
+
// Reactions are managed through the reactions endpoints.
|
|
66
|
+
true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions'>>,
|
|
67
|
+
true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
|
|
68
|
+
true as SameKeys<Infer<typeof CreateAttachmentSchema>, CreateAttachmentInput>,
|
|
69
|
+
true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id'>>
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
describe('request schemas', () => {
|
|
73
|
+
it('cover the writable fields of each domain type', () => {
|
|
74
|
+
expect(keyChecks.every(Boolean)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('strip server-assigned and unknown fields', () => {
|
|
78
|
+
const parsed = UpdateTaskSchema.parse({
|
|
79
|
+
title: 'ok',
|
|
80
|
+
id: 'hijack',
|
|
81
|
+
projectId: 'other',
|
|
82
|
+
createdAt: '1999-01-01',
|
|
83
|
+
key: 'X-1',
|
|
84
|
+
junk: true
|
|
85
|
+
});
|
|
86
|
+
expect(parsed).toEqual({ title: 'ok' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reject wrong types and out-of-range values', () => {
|
|
90
|
+
expect(UpdateTaskSchema.safeParse({ status: 123 }).success).toBe(false);
|
|
91
|
+
expect(UpdateTaskSchema.safeParse({ progress: 140 }).success).toBe(false);
|
|
92
|
+
expect(CreateTaskSchema.safeParse({ projectId: 'p1' }).success).toBe(false);
|
|
93
|
+
expect(LogTimeSchema.safeParse({ taskId: 't1', hours: 0 }).success).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('apply defaults for optional collections and enums', () => {
|
|
97
|
+
expect(CreateTeamSchema.parse({ name: 'Core' }).memberIds).toEqual([]);
|
|
98
|
+
expect(CreateIterationSchema.parse({ projectId: 'p1', name: 'S1' }).status).toBe('planning');
|
|
99
|
+
expect(CreateWorkflowSchema.parse({ name: 'W', statuses: [] }).transitions).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('keep lifecycle fields writable for imports and backfills', () => {
|
|
103
|
+
const parsed = CreateTaskSchema.parse({
|
|
104
|
+
projectId: 'p1',
|
|
105
|
+
title: 'Imported',
|
|
106
|
+
completedAt: '2026-01-02T00:00:00.000Z',
|
|
107
|
+
actualStartDate: '2026-01-01T00:00:00.000Z'
|
|
108
|
+
});
|
|
109
|
+
expect(parsed.completedAt).toBe('2026-01-02T00:00:00.000Z');
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/types/index.ts
CHANGED
|
@@ -300,6 +300,13 @@ export interface Iteration {
|
|
|
300
300
|
|
|
301
301
|
export type AuthorType = 'user' | 'agent' | 'system';
|
|
302
302
|
|
|
303
|
+
/** The identity a mutation is attributed to (see `CriticalPathEngine.withActor`). */
|
|
304
|
+
export interface Actor {
|
|
305
|
+
userId: string;
|
|
306
|
+
username?: string;
|
|
307
|
+
actorType?: AuthorType;
|
|
308
|
+
}
|
|
309
|
+
|
|
303
310
|
export interface CommentReaction {
|
|
304
311
|
emoji: string;
|
|
305
312
|
userId: string;
|