@kernhq/module-tracker 0.1.1 → 0.1.2

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.
@@ -0,0 +1,1422 @@
1
+ import { Color, Id, Timestamp, UserId, WorkspaceId } from '@kernhq/contracts'
2
+ import { ApprovalState, StatusCategory, WorkflowDefinition } from '@kernhq/workflow'
3
+ import { z } from 'zod'
4
+
5
+ export const MODULE_ID = 'tracker'
6
+
7
+ // =====================================================================================
8
+ // shared scalars
9
+ // =====================================================================================
10
+
11
+ /** Project key: 2–10 uppercase letters/digits, starts with a letter (`KRN`, `OPS2`). */
12
+ export const ProjectKey = z
13
+ .string()
14
+ .trim()
15
+ .toUpperCase()
16
+ .regex(/^[A-Z][A-Z0-9]{1,9}$/, '2–10 uppercase letters/digits, starting with a letter')
17
+ export type ProjectKey = z.infer<typeof ProjectKey>
18
+
19
+ /** Issue key `KRN-123`. */
20
+ export const IssueKey = z.string().regex(/^[A-Z][A-Z0-9]{1,9}-\d+$/, 'issue key like KRN-123')
21
+ export type IssueKey = z.infer<typeof IssueKey>
22
+
23
+ /** Machine key for types/fields/labels: lowercase snake, starts with a letter. */
24
+ export const MachineKey = z
25
+ .string()
26
+ .trim()
27
+ .min(1)
28
+ .max(48)
29
+ .regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores')
30
+ export type MachineKey = z.infer<typeof MachineKey>
31
+
32
+ /** Tiptap / ProseMirror JSON document. */
33
+ export const RichDoc = z
34
+ .object({ type: z.literal('doc'), content: z.array(z.record(z.string(), z.unknown())).optional() })
35
+ .catchall(z.unknown())
36
+ export type RichDoc = z.infer<typeof RichDoc>
37
+
38
+ /** `YYYY-MM-DD` calendar date (no time zone). */
39
+ export const DateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
40
+ export type DateOnly = z.infer<typeof DateOnly>
41
+
42
+ export const Priority = z.enum(['none', 'low', 'medium', 'high', 'urgent'])
43
+ export type Priority = z.infer<typeof Priority>
44
+ export const PRIORITY_ORDER: Record<Priority, number> = { none: 0, low: 1, medium: 2, high: 3, urgent: 4 }
45
+
46
+ export const EstimateUnit = z.enum(['points', 'hours', 'none'])
47
+ export type EstimateUnit = z.infer<typeof EstimateUnit>
48
+
49
+ export { ApprovalState, StatusCategory, WorkflowDefinition }
50
+
51
+ const ok = z.object({ ok: z.literal(true) })
52
+ export const Ok = ok
53
+
54
+ // =====================================================================================
55
+ // projects
56
+ // =====================================================================================
57
+
58
+ export const ProjectVisibility = z.enum(['workspace', 'private'])
59
+ export type ProjectVisibility = z.infer<typeof ProjectVisibility>
60
+
61
+ export const CycleSettings = z.object({
62
+ enabled: z.boolean().default(false),
63
+ lengthWeeks: z.number().int().min(1).max(8).default(2),
64
+ /** days between cycles */
65
+ cooldownDays: z.number().int().min(0).max(14).default(0),
66
+ /** move unfinished issues to the next cycle when one completes */
67
+ autoRoll: z.boolean().default(true),
68
+ /** 0 = Sunday … 6 = Saturday */
69
+ startDay: z.number().int().min(0).max(6).default(1),
70
+ /** auto-create the next upcoming cycle so there is always one planned */
71
+ autoCreateUpcoming: z.number().int().min(0).max(6).default(1),
72
+ })
73
+ export type CycleSettings = z.infer<typeof CycleSettings>
74
+
75
+ export const ProjectSettings = z.object({
76
+ estimation: EstimateUnit.default('points'),
77
+ /** allowed point values for the estimate picker (points only) */
78
+ estimateScale: z.array(z.number()).default([0, 1, 2, 3, 5, 8, 13, 21]),
79
+ cycles: CycleSettings.default({
80
+ enabled: false,
81
+ lengthWeeks: 2,
82
+ cooldownDays: 0,
83
+ autoRoll: true,
84
+ startDay: 1,
85
+ autoCreateUpcoming: 1,
86
+ }),
87
+ triage: z.object({ enabled: z.boolean().default(false) }).default({ enabled: false }),
88
+ sla: z
89
+ .object({
90
+ enabled: z.boolean().default(false),
91
+ /** hours to first response / resolution per priority */
92
+ goals: z
93
+ .partialRecord(
94
+ Priority,
95
+ z.object({
96
+ firstResponseHours: z.number().positive().optional(),
97
+ resolveHours: z.number().positive().optional(),
98
+ }),
99
+ )
100
+ .default({}),
101
+ /** status categories during which the SLA clock is paused */
102
+ pauseInCategories: z.array(StatusCategory).default(['backlog']),
103
+ })
104
+ .default({ enabled: false, goals: {}, pauseInCategories: ['backlog'] }),
105
+ /** require a resolution when moving to a done-category status */
106
+ requireResolution: z.boolean().default(false),
107
+ /** per-issue chat channel created automatically on issue creation */
108
+ autoCreateIssueChannel: z.boolean().default(false),
109
+ /** timezone used for cycle boundaries / due dates */
110
+ timezone: z.string().default('UTC'),
111
+ })
112
+ export type ProjectSettings = z.infer<typeof ProjectSettings>
113
+
114
+ export const Project = z.object({
115
+ id: Id,
116
+ workspaceId: WorkspaceId,
117
+ key: ProjectKey,
118
+ name: z.string().min(1).max(120),
119
+ description: z.string().max(4000).nullable(),
120
+ icon: z.string().max(64).nullable(),
121
+ color: Color.nullable(),
122
+ leadId: UserId.nullable(),
123
+ visibility: ProjectVisibility,
124
+ defaultAssignee: z.enum(['unassigned', 'lead']),
125
+ workflowSchemeId: Id.nullable(),
126
+ typeSchemeId: Id.nullable(),
127
+ fieldSchemeId: Id.nullable(),
128
+ settings: ProjectSettings,
129
+ /** public intake form token (null = intake disabled) */
130
+ intakeToken: z.string().nullable(),
131
+ /** last allocated issue number */
132
+ issueCounter: z.number().int().nonnegative(),
133
+ /** last allocated cycle number */
134
+ cycleCounter: z.number().int().nonnegative(),
135
+ memberCount: z.number().int().nonnegative(),
136
+ openIssueCount: z.number().int().nonnegative(),
137
+ archivedAt: Timestamp.nullable(),
138
+ createdBy: UserId.nullable(),
139
+ createdAt: Timestamp,
140
+ updatedAt: Timestamp,
141
+ })
142
+ export type Project = z.infer<typeof Project>
143
+
144
+ export const CreateProject = z.object({
145
+ key: ProjectKey,
146
+ name: z.string().min(1).max(120),
147
+ description: z.string().max(4000).optional(),
148
+ icon: z.string().max(64).optional(),
149
+ color: Color.optional(),
150
+ leadId: UserId.optional(),
151
+ visibility: ProjectVisibility.default('workspace'),
152
+ defaultAssignee: z.enum(['unassigned', 'lead']).default('unassigned'),
153
+ /** seed types/workflow from a built-in template */
154
+ template: z.enum(['software', 'kanban', 'simple', 'blank']).default('software'),
155
+ /** or from a saved project template (overrides `template`) */
156
+ templateId: Id.optional(),
157
+ settings: ProjectSettings.partial().optional(),
158
+ memberIds: z.array(UserId).max(500).default([]),
159
+ })
160
+ export type CreateProject = z.infer<typeof CreateProject>
161
+
162
+ export const UpdateProject = z.object({
163
+ name: z.string().min(1).max(120).optional(),
164
+ description: z.string().max(4000).nullable().optional(),
165
+ icon: z.string().max(64).nullable().optional(),
166
+ color: Color.nullable().optional(),
167
+ leadId: UserId.nullable().optional(),
168
+ visibility: ProjectVisibility.optional(),
169
+ defaultAssignee: z.enum(['unassigned', 'lead']).optional(),
170
+ workflowSchemeId: Id.nullable().optional(),
171
+ typeSchemeId: Id.nullable().optional(),
172
+ fieldSchemeId: Id.nullable().optional(),
173
+ settings: ProjectSettings.partial().optional(),
174
+ })
175
+ export type UpdateProject = z.infer<typeof UpdateProject>
176
+
177
+ /** Project member: a user with a project role (mirrored as kernel authz binding at scope `project`). */
178
+ export const ProjectRole = z.enum(['admin', 'member', 'viewer'])
179
+ export type ProjectRole = z.infer<typeof ProjectRole>
180
+ export const ProjectMember = z.object({
181
+ projectId: Id,
182
+ userId: UserId,
183
+ role: ProjectRole,
184
+ addedBy: UserId.nullable(),
185
+ addedAt: Timestamp,
186
+ })
187
+ export type ProjectMember = z.infer<typeof ProjectMember>
188
+
189
+ /** Reusable project blueprint (types, workflow, fields, labels, sample views). */
190
+ export const ProjectTemplate = z.object({
191
+ id: Id,
192
+ workspaceId: WorkspaceId.nullable(),
193
+ key: MachineKey,
194
+ name: z.string().min(1).max(120),
195
+ description: z.string().max(1000).nullable(),
196
+ icon: z.string().max(64).nullable(),
197
+ /** a full ProjectTemplateBody JSON */
198
+ body: z.record(z.string(), z.unknown()),
199
+ builtin: z.boolean(),
200
+ createdAt: Timestamp,
201
+ })
202
+ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
203
+
204
+ // =====================================================================================
205
+ // work item types & hierarchy
206
+ // =====================================================================================
207
+
208
+ /** -1 sub-item · 0 standard (task/bug/story) · 1 epic · 2 initiative. */
209
+ export const HierarchyLevel = z.number().int().min(-1).max(2)
210
+ export type HierarchyLevel = z.infer<typeof HierarchyLevel>
211
+
212
+ export const FieldLayoutItem = z.object({
213
+ /** system field name (`priority`, `dueDate`…) or custom field id */
214
+ fieldId: z.string().min(1),
215
+ section: z.enum(['main', 'sidebar', 'hidden']).default('sidebar'),
216
+ required: z.boolean().default(false),
217
+ hidden: z.boolean().default(false),
218
+ order: z.number().int().default(0),
219
+ })
220
+ export type FieldLayoutItem = z.infer<typeof FieldLayoutItem>
221
+
222
+ export const WorkItemType = z.object({
223
+ id: Id,
224
+ workspaceId: WorkspaceId,
225
+ /** null = workspace-level type shared by all projects */
226
+ projectId: Id.nullable(),
227
+ key: MachineKey,
228
+ name: z.string().min(1).max(60),
229
+ description: z.string().max(500).nullable(),
230
+ icon: z.string().max(64).nullable(),
231
+ color: Color.nullable(),
232
+ level: HierarchyLevel,
233
+ isDefault: z.boolean(),
234
+ /** explicit workflow; null → project's workflow scheme / default workflow */
235
+ workflowId: Id.nullable(),
236
+ fieldLayout: z.array(FieldLayoutItem),
237
+ /** default description for new items of this type */
238
+ templateBody: RichDoc.nullable(),
239
+ order: z.number().int(),
240
+ archivedAt: Timestamp.nullable(),
241
+ createdAt: Timestamp,
242
+ updatedAt: Timestamp,
243
+ })
244
+ export type WorkItemType = z.infer<typeof WorkItemType>
245
+
246
+ export const UpsertWorkItemType = WorkItemType.pick({
247
+ key: true,
248
+ name: true,
249
+ level: true,
250
+ }).extend({
251
+ projectId: Id.nullable().optional(),
252
+ description: z.string().max(500).nullable().optional(),
253
+ icon: z.string().max(64).nullable().optional(),
254
+ color: Color.nullable().optional(),
255
+ isDefault: z.boolean().optional(),
256
+ workflowId: Id.nullable().optional(),
257
+ fieldLayout: z.array(FieldLayoutItem).optional(),
258
+ templateBody: RichDoc.nullable().optional(),
259
+ order: z.number().int().optional(),
260
+ })
261
+ export type UpsertWorkItemType = z.infer<typeof UpsertWorkItemType>
262
+
263
+ /** Which levels may parent which: stored per workspace; default: 2→1→0→-1 (strict) with `allowSkip`. */
264
+ export const HierarchyRules = z.object({
265
+ /** allow e.g. an initiative (2) to directly parent a task (0) */
266
+ allowSkipLevels: z.boolean().default(true),
267
+ /** allow same-level parenting (task under task) */
268
+ allowSameLevel: z.boolean().default(false),
269
+ /** max depth of nested sub-items */
270
+ maxSubItemDepth: z.number().int().min(1).max(5).default(1),
271
+ })
272
+ export type HierarchyRules = z.infer<typeof HierarchyRules>
273
+
274
+ /** Type scheme: which types a project uses (+ default) – null projectTypeIds = all workspace types. */
275
+ export const TypeScheme = z.object({
276
+ id: Id,
277
+ workspaceId: WorkspaceId,
278
+ name: z.string().min(1).max(120),
279
+ typeIds: z.array(Id),
280
+ defaultTypeId: Id.nullable(),
281
+ createdAt: Timestamp,
282
+ })
283
+ export type TypeScheme = z.infer<typeof TypeScheme>
284
+
285
+ // =====================================================================================
286
+ // custom fields
287
+ // =====================================================================================
288
+
289
+ export const FieldType = z.enum([
290
+ 'text',
291
+ 'textarea',
292
+ 'number',
293
+ 'date',
294
+ 'datetime',
295
+ 'select',
296
+ 'multiselect',
297
+ 'user',
298
+ 'multiuser',
299
+ 'label',
300
+ 'url',
301
+ 'checkbox',
302
+ 'relation',
303
+ 'formula',
304
+ ])
305
+ export type FieldType = z.infer<typeof FieldType>
306
+
307
+ export const FieldOption = z.object({
308
+ id: z.string().min(1).max(64),
309
+ label: z.string().min(1).max(120),
310
+ color: Color.nullable().default(null),
311
+ order: z.number().int().default(0),
312
+ archived: z.boolean().default(false),
313
+ })
314
+ export type FieldOption = z.infer<typeof FieldOption>
315
+
316
+ /** Type-specific configuration. */
317
+ export const FieldConfig = z
318
+ .object({
319
+ /** number */
320
+ min: z.number().optional(),
321
+ max: z.number().optional(),
322
+ precision: z.number().int().min(0).max(6).optional(),
323
+ unit: z.string().max(16).optional(),
324
+ /** text */
325
+ maxLength: z.number().int().positive().optional(),
326
+ pattern: z.string().max(200).optional(),
327
+ /** relation: restrict to item types/projects */
328
+ relationTypeIds: z.array(Id).optional(),
329
+ relationProjectIds: z.array(Id).optional(),
330
+ relationMultiple: z.boolean().optional(),
331
+ /** formula: expression over other fields, e.g. `{estimate} * 2` or `daysBetween({startDate},{dueDate})` */
332
+ formula: z.string().max(500).optional(),
333
+ formulaResult: z.enum(['number', 'text', 'date', 'boolean']).optional(),
334
+ /** datetime/date: include time-of-day */
335
+ includeTime: z.boolean().optional(),
336
+ })
337
+ .catchall(z.unknown())
338
+ export type FieldConfig = z.infer<typeof FieldConfig>
339
+
340
+ export const FieldDef = z.object({
341
+ id: Id,
342
+ workspaceId: WorkspaceId,
343
+ /** null = workspace-level (available to all projects) */
344
+ projectId: Id.nullable(),
345
+ /** machine key, referenced in KQL as `cf.<key>` and in `issue.custom[key]` */
346
+ key: MachineKey,
347
+ name: z.string().min(1).max(80),
348
+ description: z.string().max(500).nullable(),
349
+ type: FieldType,
350
+ options: z.array(FieldOption),
351
+ defaultValue: z.unknown().nullable(),
352
+ config: FieldConfig,
353
+ searchable: z.boolean(),
354
+ required: z.boolean(),
355
+ /** show in list/board cards by default */
356
+ showInCards: z.boolean(),
357
+ order: z.number().int(),
358
+ archivedAt: Timestamp.nullable(),
359
+ createdAt: Timestamp,
360
+ updatedAt: Timestamp,
361
+ })
362
+ export type FieldDef = z.infer<typeof FieldDef>
363
+
364
+ export const UpsertFieldDef = z.object({
365
+ projectId: Id.nullable().optional(),
366
+ key: MachineKey,
367
+ name: z.string().min(1).max(80),
368
+ description: z.string().max(500).nullable().optional(),
369
+ type: FieldType,
370
+ options: z.array(FieldOption).optional(),
371
+ defaultValue: z.unknown().nullable().optional(),
372
+ config: FieldConfig.optional(),
373
+ searchable: z.boolean().optional(),
374
+ required: z.boolean().optional(),
375
+ showInCards: z.boolean().optional(),
376
+ order: z.number().int().optional(),
377
+ })
378
+ export type UpsertFieldDef = z.infer<typeof UpsertFieldDef>
379
+
380
+ /** Field scheme: which custom fields a project exposes (null = all applicable). */
381
+ export const FieldScheme = z.object({
382
+ id: Id,
383
+ workspaceId: WorkspaceId,
384
+ name: z.string().min(1).max(120),
385
+ fieldIds: z.array(Id),
386
+ createdAt: Timestamp,
387
+ })
388
+ export type FieldScheme = z.infer<typeof FieldScheme>
389
+
390
+ // =====================================================================================
391
+ // workflows
392
+ // =====================================================================================
393
+
394
+ export const Workflow = z.object({
395
+ id: Id,
396
+ workspaceId: WorkspaceId,
397
+ /** null = workspace-level workflow available to all projects */
398
+ projectId: Id.nullable(),
399
+ name: z.string().min(1).max(120),
400
+ description: z.string().max(1000).nullable(),
401
+ definition: WorkflowDefinition,
402
+ isDefault: z.boolean(),
403
+ /** number of issues currently using it (computed) */
404
+ usageCount: z.number().int().nonnegative().optional(),
405
+ archivedAt: Timestamp.nullable(),
406
+ createdAt: Timestamp,
407
+ updatedAt: Timestamp,
408
+ })
409
+ export type Workflow = z.infer<typeof Workflow>
410
+
411
+ export const UpsertWorkflow = z.object({
412
+ projectId: Id.nullable().optional(),
413
+ name: z.string().min(1).max(120),
414
+ description: z.string().max(1000).nullable().optional(),
415
+ definition: WorkflowDefinition,
416
+ isDefault: z.boolean().optional(),
417
+ })
418
+ export type UpsertWorkflow = z.infer<typeof UpsertWorkflow>
419
+
420
+ /** Maps item types to workflows for a project (typeId → workflowId; `defaultWorkflowId` otherwise). */
421
+ export const WorkflowScheme = z.object({
422
+ id: Id,
423
+ workspaceId: WorkspaceId,
424
+ name: z.string().min(1).max(120),
425
+ defaultWorkflowId: Id,
426
+ mappings: z.array(z.object({ typeId: Id, workflowId: Id })),
427
+ createdAt: Timestamp,
428
+ updatedAt: Timestamp,
429
+ })
430
+ export type WorkflowScheme = z.infer<typeof WorkflowScheme>
431
+ export const UpsertWorkflowScheme = WorkflowScheme.pick({
432
+ name: true,
433
+ defaultWorkflowId: true,
434
+ mappings: true,
435
+ })
436
+
437
+ /** One status as exposed to the UI together with the workflow it belongs to. */
438
+ export const StatusInfo = z.object({
439
+ id: z.string(),
440
+ name: z.string(),
441
+ category: StatusCategory,
442
+ color: z.string().nullable(),
443
+ order: z.number().int(),
444
+ workflowId: Id,
445
+ })
446
+ export type StatusInfo = z.infer<typeof StatusInfo>
447
+
448
+ /** Available transition for an issue, as seen by the UI. */
449
+ export const AvailableTransition = z.object({
450
+ id: z.string(),
451
+ name: z.string(),
452
+ toStatusId: z.string(),
453
+ toStatus: StatusInfo,
454
+ allowed: z.boolean(),
455
+ reasons: z.array(z.object({ kind: z.string(), message: z.string(), field: z.string().optional() })),
456
+ requiresApproval: z.boolean(),
457
+ screen: z.object({ fields: z.array(z.string()), comment: z.boolean() }).nullable(),
458
+ hidden: z.boolean(),
459
+ })
460
+ export type AvailableTransition = z.infer<typeof AvailableTransition>
461
+
462
+ // =====================================================================================
463
+ // issues
464
+ // =====================================================================================
465
+
466
+ export const RelationType = z.enum([
467
+ 'blocks',
468
+ 'blocked_by',
469
+ 'relates',
470
+ 'duplicates',
471
+ 'duplicated_by',
472
+ 'clones',
473
+ 'cloned_by',
474
+ ])
475
+ export type RelationType = z.infer<typeof RelationType>
476
+ /** inverse relation for the other side */
477
+ export const RELATION_INVERSE: Record<RelationType, RelationType> = {
478
+ blocks: 'blocked_by',
479
+ blocked_by: 'blocks',
480
+ relates: 'relates',
481
+ duplicates: 'duplicated_by',
482
+ duplicated_by: 'duplicates',
483
+ clones: 'cloned_by',
484
+ cloned_by: 'clones',
485
+ }
486
+
487
+ export const RelationSummary = z.object({
488
+ blocks: z.number().int().nonnegative(),
489
+ blockedBy: z.number().int().nonnegative(),
490
+ /** blockers that are still open */
491
+ openBlockers: z.number().int().nonnegative(),
492
+ relates: z.number().int().nonnegative(),
493
+ duplicates: z.number().int().nonnegative(),
494
+ subItems: z.number().int().nonnegative(),
495
+ subItemsDone: z.number().int().nonnegative(),
496
+ })
497
+ export type RelationSummary = z.infer<typeof RelationSummary>
498
+
499
+ export const SlaState = z.object({
500
+ firstResponseDueAt: Timestamp.nullable(),
501
+ firstRespondedAt: Timestamp.nullable(),
502
+ resolveDueAt: Timestamp.nullable(),
503
+ pausedAt: Timestamp.nullable(),
504
+ /** total paused seconds */
505
+ pausedSec: z.number().int().nonnegative(),
506
+ breached: z.boolean(),
507
+ })
508
+ export type SlaState = z.infer<typeof SlaState>
509
+
510
+ export const Issue = z.object({
511
+ id: Id,
512
+ workspaceId: WorkspaceId,
513
+ projectId: Id,
514
+ key: IssueKey,
515
+ number: z.number().int().positive(),
516
+ typeId: Id,
517
+ title: z.string().min(1).max(500),
518
+ description: RichDoc.nullable(),
519
+ /** plain-text rendering of description (search, previews) */
520
+ descriptionText: z.string(),
521
+ statusId: z.string(),
522
+ statusCategory: StatusCategory,
523
+ priority: Priority,
524
+ assigneeIds: z.array(UserId),
525
+ reporterId: UserId.nullable(),
526
+ creatorId: UserId.nullable(),
527
+ /** label ids */
528
+ labelIds: z.array(Id),
529
+ componentIds: z.array(Id),
530
+ /** fix versions */
531
+ versionIds: z.array(Id),
532
+ affectsVersionIds: z.array(Id),
533
+ cycleId: Id.nullable(),
534
+ milestoneId: Id.nullable(),
535
+ parentId: Id.nullable(),
536
+ /** fractional index for manual ordering (backlog / board) */
537
+ rank: z.string(),
538
+ estimate: z.number().nullable(),
539
+ estimateUnit: EstimateUnit,
540
+ startDate: DateOnly.nullable(),
541
+ dueDate: DateOnly.nullable(),
542
+ completedAt: Timestamp.nullable(),
543
+ cancelledAt: Timestamp.nullable(),
544
+ resolution: z.string().max(64).nullable(),
545
+ /** custom field values keyed by field key */
546
+ custom: z.record(z.string(), z.unknown()),
547
+ watcherIds: z.array(UserId),
548
+ subscriberCount: z.number().int().nonnegative(),
549
+ commentCount: z.number().int().nonnegative(),
550
+ attachmentCount: z.number().int().nonnegative(),
551
+ relationSummary: RelationSummary,
552
+ timeSpentSec: z.number().int().nonnegative(),
553
+ remainingSec: z.number().int().nonnegative().nullable(),
554
+ originalEstimateSec: z.number().int().nonnegative().nullable(),
555
+ sla: SlaState.nullable(),
556
+ triage: z.boolean(),
557
+ /** hidden from the triage queue until this time */
558
+ snoozedUntil: Timestamp.nullable(),
559
+ /** external origin: email/intake/import */
560
+ source: z.enum(['app', 'email', 'intake', 'import', 'api', 'automation', 'recurring']),
561
+ /** external reference (email message id, jira key…) */
562
+ externalRef: z.string().nullable(),
563
+ /** per-issue chat channel id once opened */
564
+ chatChannelId: Id.nullable(),
565
+ archivedAt: Timestamp.nullable(),
566
+ createdAt: Timestamp,
567
+ updatedAt: Timestamp,
568
+ lastActivityAt: Timestamp,
569
+ })
570
+ export type Issue = z.infer<typeof Issue>
571
+
572
+ /** Lightweight issue for lists/boards/pickers. */
573
+ export const IssueSummary = Issue.pick({
574
+ id: true,
575
+ workspaceId: true,
576
+ projectId: true,
577
+ key: true,
578
+ number: true,
579
+ typeId: true,
580
+ title: true,
581
+ statusId: true,
582
+ statusCategory: true,
583
+ priority: true,
584
+ assigneeIds: true,
585
+ labelIds: true,
586
+ cycleId: true,
587
+ parentId: true,
588
+ rank: true,
589
+ estimate: true,
590
+ dueDate: true,
591
+ startDate: true,
592
+ triage: true,
593
+ archivedAt: true,
594
+ updatedAt: true,
595
+ })
596
+ export type IssueSummary = z.infer<typeof IssueSummary>
597
+
598
+ /** Fields an issue may be created with. */
599
+ export const CreateIssue = z.object({
600
+ projectId: Id,
601
+ typeId: Id.optional(),
602
+ title: z.string().min(1).max(500),
603
+ description: RichDoc.nullable().optional(),
604
+ statusId: z.string().optional(),
605
+ priority: Priority.optional(),
606
+ assigneeIds: z.array(UserId).max(20).optional(),
607
+ reporterId: UserId.optional(),
608
+ labelIds: z.array(Id).max(50).optional(),
609
+ componentIds: z.array(Id).max(20).optional(),
610
+ versionIds: z.array(Id).max(20).optional(),
611
+ affectsVersionIds: z.array(Id).max(20).optional(),
612
+ cycleId: Id.nullable().optional(),
613
+ milestoneId: Id.nullable().optional(),
614
+ parentId: Id.nullable().optional(),
615
+ estimate: z.number().nullable().optional(),
616
+ startDate: DateOnly.nullable().optional(),
617
+ dueDate: DateOnly.nullable().optional(),
618
+ originalEstimateSec: z.number().int().nonnegative().nullable().optional(),
619
+ custom: z.record(z.string(), z.unknown()).optional(),
620
+ watcherIds: z.array(UserId).optional(),
621
+ triage: z.boolean().optional(),
622
+ /** issue template to apply first */
623
+ templateId: Id.optional(),
624
+ /** place relative to another issue in rank order */
625
+ rankAfterId: Id.optional(),
626
+ rankBeforeId: Id.optional(),
627
+ /** core file ids to attach */
628
+ attachmentIds: z.array(Id).max(50).optional(),
629
+ })
630
+ export type CreateIssue = z.infer<typeof CreateIssue>
631
+
632
+ /** Patch for update/bulk-update. `null` clears nullable fields. Arrays replace unless `*Add/*Remove` used. */
633
+ export const UpdateIssue = z.object({
634
+ typeId: Id.optional(),
635
+ title: z.string().min(1).max(500).optional(),
636
+ description: RichDoc.nullable().optional(),
637
+ priority: Priority.optional(),
638
+ assigneeIds: z.array(UserId).max(20).optional(),
639
+ assigneeAdd: z.array(UserId).optional(),
640
+ assigneeRemove: z.array(UserId).optional(),
641
+ reporterId: UserId.nullable().optional(),
642
+ labelIds: z.array(Id).max(50).optional(),
643
+ labelAdd: z.array(Id).optional(),
644
+ labelRemove: z.array(Id).optional(),
645
+ componentIds: z.array(Id).max(20).optional(),
646
+ versionIds: z.array(Id).max(20).optional(),
647
+ affectsVersionIds: z.array(Id).max(20).optional(),
648
+ cycleId: Id.nullable().optional(),
649
+ milestoneId: Id.nullable().optional(),
650
+ parentId: Id.nullable().optional(),
651
+ estimate: z.number().nullable().optional(),
652
+ startDate: DateOnly.nullable().optional(),
653
+ dueDate: DateOnly.nullable().optional(),
654
+ resolution: z.string().max(64).nullable().optional(),
655
+ originalEstimateSec: z.number().int().nonnegative().nullable().optional(),
656
+ remainingSec: z.number().int().nonnegative().nullable().optional(),
657
+ /** merged into existing custom values; `null` value removes a key */
658
+ custom: z.record(z.string(), z.unknown()).optional(),
659
+ triage: z.boolean().optional(),
660
+ })
661
+ export type UpdateIssue = z.infer<typeof UpdateIssue>
662
+
663
+ export const BulkResult = z.object({
664
+ results: z.array(
665
+ z.object({
666
+ id: Id,
667
+ ok: z.boolean(),
668
+ error: z.object({ code: z.string(), message: z.string() }).optional(),
669
+ }),
670
+ ),
671
+ succeeded: z.number().int().nonnegative(),
672
+ failed: z.number().int().nonnegative(),
673
+ })
674
+ export type BulkResult = z.infer<typeof BulkResult>
675
+
676
+ /** Activity/history entry for an issue (projection of core activity + status history). */
677
+ export const IssueHistoryEntry = z.object({
678
+ id: Id,
679
+ issueId: Id,
680
+ actorId: UserId.nullable(),
681
+ action: z.string(),
682
+ changes: z.array(z.object({ field: z.string(), from: z.unknown(), to: z.unknown() })),
683
+ data: z.record(z.string(), z.unknown()),
684
+ occurredAt: Timestamp,
685
+ })
686
+ export type IssueHistoryEntry = z.infer<typeof IssueHistoryEntry>
687
+
688
+ export const StatusHistoryEntry = z.object({
689
+ id: Id,
690
+ issueId: Id,
691
+ fromStatusId: z.string().nullable(),
692
+ toStatusId: z.string(),
693
+ fromCategory: StatusCategory.nullable(),
694
+ toCategory: StatusCategory,
695
+ actorId: UserId.nullable(),
696
+ transitionId: z.string().nullable(),
697
+ /** seconds spent in the previous status */
698
+ durationSec: z.number().int().nonnegative().nullable(),
699
+ occurredAt: Timestamp,
700
+ })
701
+ export type StatusHistoryEntry = z.infer<typeof StatusHistoryEntry>
702
+
703
+ // ---------- comments ----------
704
+
705
+ export const ReactionSummary = z.object({
706
+ emoji: z.string(),
707
+ count: z.number().int(),
708
+ userIds: z.array(UserId),
709
+ })
710
+ export type ReactionSummary = z.infer<typeof ReactionSummary>
711
+
712
+ export const Comment = z.object({
713
+ id: Id,
714
+ workspaceId: WorkspaceId,
715
+ issueId: Id,
716
+ /** threaded reply */
717
+ parentId: Id.nullable(),
718
+ authorId: UserId.nullable(),
719
+ body: RichDoc,
720
+ bodyText: z.string(),
721
+ mentionIds: z.array(UserId),
722
+ reactions: z.array(ReactionSummary),
723
+ /** internal comments are hidden from portal/email requesters */
724
+ internal: z.boolean(),
725
+ /** comment created from an inbound email */
726
+ source: z.enum(['app', 'email', 'automation', 'system']),
727
+ replyCount: z.number().int().nonnegative(),
728
+ editedAt: Timestamp.nullable(),
729
+ deletedAt: Timestamp.nullable(),
730
+ createdAt: Timestamp,
731
+ updatedAt: Timestamp,
732
+ })
733
+ export type Comment = z.infer<typeof Comment>
734
+
735
+ // ---------- relations / attachments / links ----------
736
+
737
+ export const Relation = z.object({
738
+ id: Id,
739
+ workspaceId: WorkspaceId,
740
+ type: RelationType,
741
+ fromIssueId: Id,
742
+ toIssueId: Id,
743
+ createdBy: UserId.nullable(),
744
+ createdAt: Timestamp,
745
+ })
746
+ export type Relation = z.infer<typeof Relation>
747
+
748
+ /** Relation as seen from one issue's perspective. */
749
+ export const RelationView = z.object({
750
+ id: Id,
751
+ type: RelationType,
752
+ issue: IssueSummary,
753
+ createdAt: Timestamp,
754
+ })
755
+ export type RelationView = z.infer<typeof RelationView>
756
+
757
+ export const Attachment = z.object({
758
+ id: Id,
759
+ workspaceId: WorkspaceId,
760
+ issueId: Id,
761
+ /** core file id */
762
+ fileId: Id,
763
+ name: z.string(),
764
+ mimeType: z.string(),
765
+ size: z.number().int().nonnegative(),
766
+ uploadedBy: UserId.nullable(),
767
+ createdAt: Timestamp,
768
+ })
769
+ export type Attachment = z.infer<typeof Attachment>
770
+
771
+ export const Link = z.object({
772
+ id: Id,
773
+ workspaceId: WorkspaceId,
774
+ issueId: Id,
775
+ url: z.string().url(),
776
+ title: z.string().max(300).nullable(),
777
+ /** e.g. `github_pr`, `doc`, `generic` */
778
+ kind: z.string().max(32),
779
+ createdBy: UserId.nullable(),
780
+ createdAt: Timestamp,
781
+ })
782
+ export type Link = z.infer<typeof Link>
783
+
784
+ // ---------- time tracking ----------
785
+
786
+ export const Worklog = z.object({
787
+ id: Id,
788
+ workspaceId: WorkspaceId,
789
+ projectId: Id,
790
+ issueId: Id,
791
+ userId: UserId,
792
+ startedAt: Timestamp,
793
+ durationSec: z.number().int().positive(),
794
+ note: z.string().max(2000).nullable(),
795
+ billable: z.boolean(),
796
+ createdAt: Timestamp,
797
+ updatedAt: Timestamp,
798
+ })
799
+ export type Worklog = z.infer<typeof Worklog>
800
+ export const UpsertWorklog = z.object({
801
+ startedAt: Timestamp.optional(),
802
+ durationSec: z.number().int().positive(),
803
+ note: z.string().max(2000).nullable().optional(),
804
+ billable: z.boolean().optional(),
805
+ /** reduce remaining estimate by: auto (duration) | leave | set */
806
+ adjustRemaining: z.enum(['auto', 'leave', 'set']).default('auto'),
807
+ remainingSec: z.number().int().nonnegative().optional(),
808
+ })
809
+
810
+ export const Timer = z.object({
811
+ id: Id,
812
+ workspaceId: WorkspaceId,
813
+ issueId: Id,
814
+ userId: UserId,
815
+ startedAt: Timestamp,
816
+ note: z.string().max(2000).nullable(),
817
+ })
818
+ export type Timer = z.infer<typeof Timer>
819
+
820
+ /** Timesheet cell: seconds per (user, issue/project, day). */
821
+ export const TimesheetRow = z.object({
822
+ userId: UserId,
823
+ projectId: Id,
824
+ issueId: Id.nullable(),
825
+ issueKey: z.string().nullable(),
826
+ date: DateOnly,
827
+ durationSec: z.number().int().nonnegative(),
828
+ billableSec: z.number().int().nonnegative(),
829
+ })
830
+ export type TimesheetRow = z.infer<typeof TimesheetRow>
831
+
832
+ // ---------- templates / recurring ----------
833
+
834
+ export const IssueTemplate = z.object({
835
+ id: Id,
836
+ workspaceId: WorkspaceId,
837
+ projectId: Id.nullable(),
838
+ name: z.string().min(1).max(120),
839
+ description: z.string().max(500).nullable(),
840
+ typeId: Id.nullable(),
841
+ /** CreateIssue-shaped defaults (without projectId) */
842
+ defaults: CreateIssue.omit({ projectId: true, templateId: true }).partial(),
843
+ /** sub-items to create with the issue */
844
+ subItems: z.array(z.object({ title: z.string().min(1).max(500), typeId: Id.optional() })),
845
+ createdBy: UserId.nullable(),
846
+ createdAt: Timestamp,
847
+ updatedAt: Timestamp,
848
+ })
849
+ export type IssueTemplate = z.infer<typeof IssueTemplate>
850
+ export const UpsertIssueTemplate = IssueTemplate.pick({ name: true }).extend({
851
+ projectId: Id.nullable().optional(),
852
+ description: z.string().max(500).nullable().optional(),
853
+ typeId: Id.nullable().optional(),
854
+ defaults: IssueTemplate.shape.defaults.optional(),
855
+ subItems: IssueTemplate.shape.subItems.optional(),
856
+ })
857
+
858
+ /** Recurrence rule (simplified RRULE). */
859
+ export const RecurrenceRule = z.object({
860
+ freq: z.enum(['daily', 'weekly', 'monthly', 'yearly']),
861
+ interval: z.number().int().min(1).max(365).default(1),
862
+ /** weekly: 0–6; monthly: day of month 1–31 */
863
+ byWeekday: z.array(z.number().int().min(0).max(6)).optional(),
864
+ byMonthDay: z.number().int().min(1).max(31).optional(),
865
+ /** HH:mm in project timezone */
866
+ at: z
867
+ .string()
868
+ .regex(/^\d{2}:\d{2}$/)
869
+ .default('09:00'),
870
+ until: Timestamp.nullable().optional(),
871
+ count: z.number().int().positive().nullable().optional(),
872
+ })
873
+ export type RecurrenceRule = z.infer<typeof RecurrenceRule>
874
+
875
+ export const RecurringIssue = z.object({
876
+ id: Id,
877
+ workspaceId: WorkspaceId,
878
+ projectId: Id,
879
+ name: z.string().min(1).max(120),
880
+ rule: RecurrenceRule,
881
+ /** template to instantiate (CreateIssue defaults) */
882
+ defaults: IssueTemplate.shape.defaults,
883
+ enabled: z.boolean(),
884
+ nextRunAt: Timestamp.nullable(),
885
+ lastRunAt: Timestamp.nullable(),
886
+ lastIssueId: Id.nullable(),
887
+ runCount: z.number().int().nonnegative(),
888
+ createdBy: UserId.nullable(),
889
+ createdAt: Timestamp,
890
+ updatedAt: Timestamp,
891
+ })
892
+ export type RecurringIssue = z.infer<typeof RecurringIssue>
893
+ export const UpsertRecurringIssue = RecurringIssue.pick({ name: true, rule: true, defaults: true }).extend({
894
+ enabled: z.boolean().optional(),
895
+ })
896
+
897
+ // =====================================================================================
898
+ // planning: cycles, milestones, versions, components, labels
899
+ // =====================================================================================
900
+
901
+ export const CycleStatus = z.enum(['upcoming', 'active', 'completed'])
902
+ export type CycleStatus = z.infer<typeof CycleStatus>
903
+
904
+ export const Cycle = z.object({
905
+ id: Id,
906
+ workspaceId: WorkspaceId,
907
+ projectId: Id,
908
+ number: z.number().int().positive(),
909
+ name: z.string().min(1).max(120),
910
+ goal: z.string().max(2000).nullable(),
911
+ startAt: Timestamp,
912
+ endAt: Timestamp,
913
+ status: CycleStatus,
914
+ startedAt: Timestamp.nullable(),
915
+ completedAt: Timestamp.nullable(),
916
+ /** issues carried over from the previous cycle when this one started */
917
+ carryOverCount: z.number().int().nonnegative(),
918
+ /** snapshot of scope (estimate sum) at start / issue counts */
919
+ stats: z.object({
920
+ total: z.number().int().nonnegative(),
921
+ done: z.number().int().nonnegative(),
922
+ estimateTotal: z.number().nonnegative(),
923
+ estimateDone: z.number().nonnegative(),
924
+ }),
925
+ createdAt: Timestamp,
926
+ updatedAt: Timestamp,
927
+ })
928
+ export type Cycle = z.infer<typeof Cycle>
929
+ export const UpsertCycle = z.object({
930
+ name: z.string().min(1).max(120).optional(),
931
+ goal: z.string().max(2000).nullable().optional(),
932
+ startAt: Timestamp,
933
+ endAt: Timestamp,
934
+ })
935
+
936
+ export const Milestone = z.object({
937
+ id: Id,
938
+ workspaceId: WorkspaceId,
939
+ projectId: Id,
940
+ name: z.string().min(1).max(120),
941
+ description: z.string().max(2000).nullable(),
942
+ targetDate: DateOnly.nullable(),
943
+ status: z.enum(['open', 'completed', 'cancelled']),
944
+ stats: z.object({ total: z.number().int().nonnegative(), done: z.number().int().nonnegative() }),
945
+ completedAt: Timestamp.nullable(),
946
+ createdAt: Timestamp,
947
+ updatedAt: Timestamp,
948
+ })
949
+ export type Milestone = z.infer<typeof Milestone>
950
+ export const UpsertMilestone = z.object({
951
+ name: z.string().min(1).max(120),
952
+ description: z.string().max(2000).nullable().optional(),
953
+ targetDate: DateOnly.nullable().optional(),
954
+ status: z.enum(['open', 'completed', 'cancelled']).optional(),
955
+ })
956
+
957
+ export const VersionStatus = z.enum(['unreleased', 'released', 'archived'])
958
+ export type VersionStatus = z.infer<typeof VersionStatus>
959
+ export const Version = z.object({
960
+ id: Id,
961
+ workspaceId: WorkspaceId,
962
+ projectId: Id,
963
+ name: z.string().min(1).max(120),
964
+ description: z.string().max(2000).nullable(),
965
+ status: VersionStatus,
966
+ startDate: DateOnly.nullable(),
967
+ releaseDate: DateOnly.nullable(),
968
+ releasedAt: Timestamp.nullable(),
969
+ stats: z.object({ total: z.number().int().nonnegative(), done: z.number().int().nonnegative() }),
970
+ order: z.number().int(),
971
+ createdAt: Timestamp,
972
+ updatedAt: Timestamp,
973
+ })
974
+ export type Version = z.infer<typeof Version>
975
+ export const UpsertVersion = z.object({
976
+ name: z.string().min(1).max(120),
977
+ description: z.string().max(2000).nullable().optional(),
978
+ startDate: DateOnly.nullable().optional(),
979
+ releaseDate: DateOnly.nullable().optional(),
980
+ order: z.number().int().optional(),
981
+ })
982
+
983
+ export const Component = z.object({
984
+ id: Id,
985
+ workspaceId: WorkspaceId,
986
+ projectId: Id,
987
+ name: z.string().min(1).max(120),
988
+ description: z.string().max(2000).nullable(),
989
+ leadId: UserId.nullable(),
990
+ /** issues with this component default-assign to: none | lead | project default */
991
+ defaultAssignee: z.enum(['none', 'lead', 'project']),
992
+ issueCount: z.number().int().nonnegative(),
993
+ createdAt: Timestamp,
994
+ updatedAt: Timestamp,
995
+ })
996
+ export type Component = z.infer<typeof Component>
997
+ export const UpsertComponent = z.object({
998
+ name: z.string().min(1).max(120),
999
+ description: z.string().max(2000).nullable().optional(),
1000
+ leadId: UserId.nullable().optional(),
1001
+ defaultAssignee: z.enum(['none', 'lead', 'project']).optional(),
1002
+ })
1003
+
1004
+ export const Label = z.object({
1005
+ id: Id,
1006
+ workspaceId: WorkspaceId,
1007
+ /** null = workspace-level label */
1008
+ projectId: Id.nullable(),
1009
+ name: z.string().min(1).max(60),
1010
+ color: Color.nullable(),
1011
+ description: z.string().max(300).nullable(),
1012
+ /** label groups (Linear-style); labels in the same group are mutually exclusive */
1013
+ groupName: z.string().max(60).nullable(),
1014
+ issueCount: z.number().int().nonnegative(),
1015
+ archivedAt: Timestamp.nullable(),
1016
+ createdAt: Timestamp,
1017
+ })
1018
+ export type Label = z.infer<typeof Label>
1019
+ export const UpsertLabel = z.object({
1020
+ projectId: Id.nullable().optional(),
1021
+ name: z.string().min(1).max(60),
1022
+ color: Color.nullable().optional(),
1023
+ description: z.string().max(300).nullable().optional(),
1024
+ groupName: z.string().max(60).nullable().optional(),
1025
+ })
1026
+
1027
+ // =====================================================================================
1028
+ // views & boards
1029
+ // =====================================================================================
1030
+
1031
+ export const ViewLayout = z.enum(['list', 'board', 'calendar', 'timeline', 'spreadsheet'])
1032
+ export type ViewLayout = z.infer<typeof ViewLayout>
1033
+ export const ViewVisibility = z.enum(['private', 'project', 'workspace'])
1034
+ export type ViewVisibility = z.infer<typeof ViewVisibility>
1035
+
1036
+ /** Group/sort keys accepted by views and `issues.query`. */
1037
+ export const GroupBy = z.enum([
1038
+ 'none',
1039
+ 'status',
1040
+ 'statusCategory',
1041
+ 'assignee',
1042
+ 'priority',
1043
+ 'type',
1044
+ 'label',
1045
+ 'cycle',
1046
+ 'milestone',
1047
+ 'project',
1048
+ 'parent',
1049
+ 'component',
1050
+ 'version',
1051
+ 'dueDate',
1052
+ 'createdAt',
1053
+ ])
1054
+ export type GroupBy = z.infer<typeof GroupBy>
1055
+
1056
+ export const OrderBy = z.object({
1057
+ /** KQL field name (`priority`, `updated`, `rank`, `cf.severity`…) */
1058
+ field: z.string().min(1),
1059
+ dir: z.enum(['asc', 'desc']).default('asc'),
1060
+ })
1061
+ export type OrderBy = z.infer<typeof OrderBy>
1062
+
1063
+ export const BoardColumn = z.object({
1064
+ id: z.string().min(1),
1065
+ name: z.string().min(1).max(80),
1066
+ /** statuses shown in this column */
1067
+ statusIds: z.array(z.string()),
1068
+ wipLimit: z.number().int().positive().nullable().default(null),
1069
+ collapsed: z.boolean().default(false),
1070
+ })
1071
+ export type BoardColumn = z.infer<typeof BoardColumn>
1072
+
1073
+ export const ViewDisplay = z.object({
1074
+ groupBy: GroupBy.default('none'),
1075
+ subGroupBy: GroupBy.optional(),
1076
+ orderBy: z.array(OrderBy).default([{ field: 'rank', dir: 'asc' }]),
1077
+ /** visible columns (list/spreadsheet): system field names or `cf.<key>` */
1078
+ columns: z.array(z.string()).default(['key', 'title', 'status', 'assignee', 'priority', 'updated']),
1079
+ /** board: swimlane grouping */
1080
+ swimlanes: GroupBy.optional(),
1081
+ showSubItems: z.boolean().default(true),
1082
+ showEmptyGroups: z.boolean().default(true),
1083
+ showCompleted: z.enum(['all', 'none', '1d', '7d', '30d']).default('7d'),
1084
+ /** board columns (null → one column per status of the project's workflows) */
1085
+ boardColumns: z.array(BoardColumn).nullable().default(null),
1086
+ wipLimits: z.record(z.string(), z.number().int().positive()).default({}),
1087
+ /** calendar: which date field positions issues */
1088
+ calendarField: z.enum(['dueDate', 'startDate', 'createdAt']).default('dueDate'),
1089
+ /** timeline: show dependency arrows */
1090
+ showDependencies: z.boolean().default(true),
1091
+ density: z.enum(['compact', 'comfortable']).default('comfortable'),
1092
+ /** card properties to display */
1093
+ cardProps: z.array(z.string()).default(['key', 'priority', 'assignee', 'labels', 'estimate', 'dueDate']),
1094
+ })
1095
+ export type ViewDisplay = z.infer<typeof ViewDisplay>
1096
+
1097
+ /** Visual filter builder state (kept verbatim for the UI; the server only uses `kql`). */
1098
+ export const ViewFilters = z.record(z.string(), z.unknown())
1099
+
1100
+ export const View = z.object({
1101
+ id: Id,
1102
+ workspaceId: WorkspaceId,
1103
+ /** null = cross-project (workspace) view */
1104
+ projectId: Id.nullable(),
1105
+ name: z.string().min(1).max(120),
1106
+ description: z.string().max(500).nullable(),
1107
+ icon: z.string().max(64).nullable(),
1108
+ kql: z.string().max(4000),
1109
+ layout: ViewLayout,
1110
+ display: ViewDisplay,
1111
+ filters: ViewFilters,
1112
+ visibility: ViewVisibility,
1113
+ ownerId: UserId.nullable(),
1114
+ /** pinned to the sidebar for the caller */
1115
+ pinned: z.boolean(),
1116
+ /** built-in views (My issues, Triage, Backlog…) cannot be deleted */
1117
+ builtin: z.boolean(),
1118
+ order: z.number().int(),
1119
+ createdAt: Timestamp,
1120
+ updatedAt: Timestamp,
1121
+ })
1122
+ export type View = z.infer<typeof View>
1123
+ export const UpsertView = z.object({
1124
+ projectId: Id.nullable().optional(),
1125
+ name: z.string().min(1).max(120),
1126
+ description: z.string().max(500).nullable().optional(),
1127
+ icon: z.string().max(64).nullable().optional(),
1128
+ kql: z.string().max(4000).default(''),
1129
+ layout: ViewLayout.default('list'),
1130
+ display: ViewDisplay.partial().optional(),
1131
+ filters: ViewFilters.optional(),
1132
+ visibility: ViewVisibility.default('private'),
1133
+ order: z.number().int().optional(),
1134
+ })
1135
+
1136
+ // =====================================================================================
1137
+ // KQL
1138
+ // =====================================================================================
1139
+
1140
+ export const KqlError = z.object({
1141
+ message: z.string(),
1142
+ start: z.number().int().nonnegative(),
1143
+ end: z.number().int().nonnegative(),
1144
+ })
1145
+ export type KqlError = z.infer<typeof KqlError>
1146
+
1147
+ export const KqlSuggestion = z.object({
1148
+ kind: z.enum(['field', 'operator', 'value', 'keyword', 'function']),
1149
+ label: z.string(),
1150
+ insertText: z.string(),
1151
+ detail: z.string().optional(),
1152
+ })
1153
+ export type KqlSuggestion = z.infer<typeof KqlSuggestion>
1154
+
1155
+ export const KqlFieldInfo = z.object({
1156
+ name: z.string(),
1157
+ type: z.enum(['text', 'enum', 'user', 'number', 'date', 'datetime', 'boolean', 'ref', 'id', 'key']),
1158
+ label: z.string(),
1159
+ operators: z.array(z.string()),
1160
+ /** enum/ref fields: known values for autocomplete */
1161
+ values: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
1162
+ custom: z.boolean(),
1163
+ sortable: z.boolean(),
1164
+ })
1165
+ export type KqlFieldInfo = z.infer<typeof KqlFieldInfo>
1166
+
1167
+ export const KqlParseResult = z.object({
1168
+ ok: z.boolean(),
1169
+ /** normalized AST (JSON) – see `@kernhq/module-tracker/kql` for the typed shape */
1170
+ ast: z.unknown().nullable(),
1171
+ errors: z.array(KqlError),
1172
+ /** normalised, pretty-printed query */
1173
+ normalized: z.string().nullable(),
1174
+ suggestions: z.array(KqlSuggestion),
1175
+ })
1176
+ export type KqlParseResult = z.infer<typeof KqlParseResult>
1177
+
1178
+ export const IssueQueryInput = z.object({
1179
+ workspaceId: WorkspaceId,
1180
+ kql: z.string().max(4000).default(''),
1181
+ /** restrict to these projects (ACL is applied regardless) */
1182
+ projectIds: z.array(Id).optional(),
1183
+ orderBy: z.array(OrderBy).optional(),
1184
+ groupBy: GroupBy.optional(),
1185
+ cursor: z.string().optional(),
1186
+ limit: z.number().int().min(1).max(500).default(100),
1187
+ includeArchived: z.boolean().default(false),
1188
+ include: z
1189
+ .object({
1190
+ /** total count (extra query) */
1191
+ total: z.boolean().default(false),
1192
+ /** per-group counts when groupBy set */
1193
+ groupCounts: z.boolean().default(false),
1194
+ /** full Issue instead of IssueSummary */
1195
+ full: z.boolean().default(false),
1196
+ })
1197
+ .default({ total: false, groupCounts: false, full: false }),
1198
+ })
1199
+ export type IssueQueryInput = z.infer<typeof IssueQueryInput>
1200
+
1201
+ export const IssueQueryResult = z.object({
1202
+ items: z.array(Issue),
1203
+ nextCursor: z.string().nullable(),
1204
+ total: z.number().int().nonnegative().optional(),
1205
+ groups: z
1206
+ .array(
1207
+ z.object({
1208
+ key: z.string().nullable(),
1209
+ count: z.number().int().nonnegative(),
1210
+ estimate: z.number().nullable(),
1211
+ }),
1212
+ )
1213
+ .optional(),
1214
+ /** fields used in the query (for the UI to highlight) */
1215
+ fields: z.array(z.string()),
1216
+ })
1217
+ export type IssueQueryResult = z.infer<typeof IssueQueryResult>
1218
+
1219
+ // =====================================================================================
1220
+ // intake / triage / email
1221
+ // =====================================================================================
1222
+
1223
+ export const IntakeForm = z.object({
1224
+ projectId: Id,
1225
+ projectName: z.string(),
1226
+ token: z.string(),
1227
+ title: z.string(),
1228
+ description: z.string().nullable(),
1229
+ fields: z.array(
1230
+ z.object({
1231
+ key: z.string(),
1232
+ label: z.string(),
1233
+ type: z.enum(['text', 'textarea', 'email', 'select']),
1234
+ required: z.boolean(),
1235
+ options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
1236
+ }),
1237
+ ),
1238
+ /** allow file uploads on the public form */
1239
+ allowAttachments: z.boolean(),
1240
+ })
1241
+ export type IntakeForm = z.infer<typeof IntakeForm>
1242
+
1243
+ export const IntakeSubmission = z.object({
1244
+ token: z.string().min(8),
1245
+ title: z.string().min(1).max(500),
1246
+ description: z.string().max(20000).optional(),
1247
+ email: z.string().email().optional(),
1248
+ name: z.string().max(120).optional(),
1249
+ fields: z.record(z.string(), z.unknown()).optional(),
1250
+ /** honeypot – must be empty */
1251
+ website: z.string().max(0).optional(),
1252
+ })
1253
+ export type IntakeSubmission = z.infer<typeof IntakeSubmission>
1254
+
1255
+ export const EmailAddress = z.object({ address: z.string(), name: z.string().nullable().optional() })
1256
+ /** Inbound email as handed over by the mail module. */
1257
+ export const InboundEmail = z.object({
1258
+ /** project intake token (from `intake+<token>@…`) – or projectId for trusted callers */
1259
+ projectToken: z.string().optional(),
1260
+ projectId: Id.optional(),
1261
+ workspaceId: WorkspaceId.optional(),
1262
+ messageId: z.string().min(1),
1263
+ inReplyTo: z.string().nullable().optional(),
1264
+ references: z.array(z.string()).default([]),
1265
+ from: EmailAddress,
1266
+ to: z.array(EmailAddress).default([]),
1267
+ subject: z.string().max(1000),
1268
+ text: z.string().max(200000).nullable().optional(),
1269
+ html: z.string().max(500000).nullable().optional(),
1270
+ receivedAt: Timestamp.optional(),
1271
+ /** core file ids already stored by the mail module */
1272
+ attachments: z
1273
+ .array(z.object({ fileId: Id, name: z.string(), mimeType: z.string(), size: z.number().int() }))
1274
+ .default([]),
1275
+ })
1276
+ export type InboundEmail = z.infer<typeof InboundEmail>
1277
+
1278
+ export const EmailIngestResult = z.object({
1279
+ action: z.enum(['created', 'commented', 'ignored']),
1280
+ issueId: Id.nullable(),
1281
+ issueKey: z.string().nullable(),
1282
+ commentId: Id.nullable(),
1283
+ reason: z.string().optional(),
1284
+ })
1285
+ export type EmailIngestResult = z.infer<typeof EmailIngestResult>
1286
+
1287
+ // =====================================================================================
1288
+ // reports
1289
+ // =====================================================================================
1290
+
1291
+ export const BurndownPoint = z.object({
1292
+ date: DateOnly,
1293
+ remaining: z.number(),
1294
+ ideal: z.number(),
1295
+ completed: z.number(),
1296
+ scope: z.number(),
1297
+ /** scope added/removed that day */
1298
+ scopeChange: z.number(),
1299
+ })
1300
+ export const BurndownReport = z.object({
1301
+ cycle: Cycle,
1302
+ unit: EstimateUnit,
1303
+ points: z.array(BurndownPoint),
1304
+ })
1305
+ export type BurndownReport = z.infer<typeof BurndownReport>
1306
+
1307
+ export const VelocityReport = z.object({
1308
+ unit: EstimateUnit,
1309
+ cycles: z.array(
1310
+ z.object({
1311
+ cycle: Cycle.pick({ id: true, number: true, name: true, startAt: true, endAt: true, status: true }),
1312
+ committed: z.number(),
1313
+ completed: z.number(),
1314
+ committedCount: z.number().int(),
1315
+ completedCount: z.number().int(),
1316
+ }),
1317
+ ),
1318
+ average: z.number(),
1319
+ })
1320
+ export type VelocityReport = z.infer<typeof VelocityReport>
1321
+
1322
+ export const CfdReport = z.object({
1323
+ statuses: z.array(StatusInfo.pick({ id: true, name: true, category: true, color: true })),
1324
+ points: z.array(z.object({ date: DateOnly, counts: z.record(z.string(), z.number().int()) })),
1325
+ })
1326
+ export type CfdReport = z.infer<typeof CfdReport>
1327
+
1328
+ export const CreatedVsResolvedReport = z.object({
1329
+ points: z.array(
1330
+ z.object({
1331
+ date: DateOnly,
1332
+ created: z.number().int(),
1333
+ resolved: z.number().int(),
1334
+ openTotal: z.number().int(),
1335
+ }),
1336
+ ),
1337
+ })
1338
+ export type CreatedVsResolvedReport = z.infer<typeof CreatedVsResolvedReport>
1339
+
1340
+ export const TimeReport = z.object({
1341
+ from: DateOnly,
1342
+ to: DateOnly,
1343
+ totalSec: z.number().int().nonnegative(),
1344
+ billableSec: z.number().int().nonnegative(),
1345
+ rows: z.array(TimesheetRow),
1346
+ byUser: z.array(z.object({ userId: UserId, durationSec: z.number().int(), billableSec: z.number().int() })),
1347
+ byIssue: z.array(
1348
+ z.object({
1349
+ issueId: Id,
1350
+ issueKey: z.string(),
1351
+ title: z.string(),
1352
+ durationSec: z.number().int(),
1353
+ originalEstimateSec: z.number().int().nullable(),
1354
+ remainingSec: z.number().int().nullable(),
1355
+ }),
1356
+ ),
1357
+ })
1358
+ export type TimeReport = z.infer<typeof TimeReport>
1359
+
1360
+ // =====================================================================================
1361
+ // imports
1362
+ // =====================================================================================
1363
+
1364
+ export const ImportSource = z.enum(['jira', 'linear', 'csv'])
1365
+ export type ImportSource = z.infer<typeof ImportSource>
1366
+
1367
+ export const CsvMapping = z.object({
1368
+ /** CSV column → issue field (system name or `cf.<key>`) */
1369
+ columns: z.record(z.string(), z.string()),
1370
+ /** parse options */
1371
+ delimiter: z.string().max(1).default(','),
1372
+ hasHeader: z.boolean().default(true),
1373
+ dateFormat: z.string().optional(),
1374
+ /** map status names → status ids (unmapped → initial status) */
1375
+ statusMap: z.record(z.string(), z.string()).default({}),
1376
+ /** map user emails/names → user ids */
1377
+ userMap: z.record(z.string(), UserId).default({}),
1378
+ /** map type names → type ids */
1379
+ typeMap: z.record(z.string(), Id).default({}),
1380
+ priorityMap: z.record(z.string(), Priority).default({}),
1381
+ createMissingLabels: z.boolean().default(true),
1382
+ })
1383
+ export type CsvMapping = z.infer<typeof CsvMapping>
1384
+
1385
+ export const ImportJob = z.object({
1386
+ id: Id,
1387
+ workspaceId: WorkspaceId,
1388
+ projectId: Id,
1389
+ source: ImportSource,
1390
+ fileId: Id,
1391
+ mapping: z.record(z.string(), z.unknown()),
1392
+ status: z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']),
1393
+ progress: z.object({
1394
+ total: z.number().int().nonnegative(),
1395
+ processed: z.number().int().nonnegative(),
1396
+ created: z.number().int().nonnegative(),
1397
+ skipped: z.number().int().nonnegative(),
1398
+ failed: z.number().int().nonnegative(),
1399
+ }),
1400
+ errors: z.array(z.object({ row: z.number().int().nullable(), message: z.string() })),
1401
+ /** external id → issue key mapping (for relations / idempotency) */
1402
+ createdBy: UserId.nullable(),
1403
+ startedAt: Timestamp.nullable(),
1404
+ finishedAt: Timestamp.nullable(),
1405
+ createdAt: Timestamp,
1406
+ })
1407
+ export type ImportJob = z.infer<typeof ImportJob>
1408
+
1409
+ // =====================================================================================
1410
+ // approvals (transition approvals persisted per issue)
1411
+ // =====================================================================================
1412
+
1413
+ export const IssueApproval = z.object({
1414
+ id: Id,
1415
+ workspaceId: WorkspaceId,
1416
+ issueId: Id,
1417
+ transitionId: z.string(),
1418
+ state: ApprovalState,
1419
+ createdAt: Timestamp,
1420
+ updatedAt: Timestamp,
1421
+ })
1422
+ export type IssueApproval = z.infer<typeof IssueApproval>