@kernhq/module-tracker 0.1.2 → 0.2.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.
Files changed (68) hide show
  1. package/dist/contract/models.d.ts +1185 -136
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +131 -29
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/router.d.ts +1098 -356
  6. package/dist/contract/router.d.ts.map +1 -1
  7. package/dist/contract/router.js +6 -25
  8. package/dist/contract/router.js.map +1 -1
  9. package/dist/kql/fields.d.ts.map +1 -1
  10. package/dist/kql/fields.js +4 -1
  11. package/dist/kql/fields.js.map +1 -1
  12. package/dist/server/index.d.ts.map +1 -1
  13. package/dist/server/index.js +39 -6
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/server/kql/compile.d.ts.map +1 -1
  16. package/dist/server/kql/compile.js +9 -1
  17. package/dist/server/kql/compile.js.map +1 -1
  18. package/dist/server/router.d.ts +1104 -384
  19. package/dist/server/router.d.ts.map +1 -1
  20. package/dist/server/router.js +3 -15
  21. package/dist/server/router.js.map +1 -1
  22. package/dist/server/schema.d.ts +1 -136
  23. package/dist/server/schema.d.ts.map +1 -1
  24. package/dist/server/schema.js +3 -13
  25. package/dist/server/schema.js.map +1 -1
  26. package/dist/server/services/config.d.ts +1 -11
  27. package/dist/server/services/config.d.ts.map +1 -1
  28. package/dist/server/services/config.js +28 -64
  29. package/dist/server/services/config.js.map +1 -1
  30. package/dist/server/services/db.d.ts +2 -4
  31. package/dist/server/services/db.d.ts.map +1 -1
  32. package/dist/server/services/db.js +4 -12
  33. package/dist/server/services/db.js.map +1 -1
  34. package/dist/server/services/imports.d.ts.map +1 -1
  35. package/dist/server/services/imports.js +50 -4
  36. package/dist/server/services/imports.js.map +1 -1
  37. package/dist/server/services/index.d.ts +2 -0
  38. package/dist/server/services/index.d.ts.map +1 -1
  39. package/dist/server/services/index.js +3 -0
  40. package/dist/server/services/index.js.map +1 -1
  41. package/dist/server/services/issues.d.ts +7 -18
  42. package/dist/server/services/issues.d.ts.map +1 -1
  43. package/dist/server/services/issues.js +34 -18
  44. package/dist/server/services/issues.js.map +1 -1
  45. package/dist/server/services/layout.d.ts +25 -0
  46. package/dist/server/services/layout.d.ts.map +1 -0
  47. package/dist/server/services/layout.js +97 -0
  48. package/dist/server/services/layout.js.map +1 -0
  49. package/dist/server/services/planning.d.ts.map +1 -1
  50. package/dist/server/services/planning.js +4 -1
  51. package/dist/server/services/planning.js.map +1 -1
  52. package/dist/server/services/projects.d.ts.map +1 -1
  53. package/dist/server/services/projects.js +0 -1
  54. package/dist/server/services/projects.js.map +1 -1
  55. package/dist/server/services/transitions.d.ts.map +1 -1
  56. package/dist/server/services/transitions.js +36 -0
  57. package/dist/server/services/transitions.js.map +1 -1
  58. package/dist/server/services/values.d.ts +37 -0
  59. package/dist/server/services/values.d.ts.map +1 -0
  60. package/dist/server/services/values.js +172 -0
  61. package/dist/server/services/values.js.map +1 -0
  62. package/migrations/0002_field_keys_unique.sql +35 -0
  63. package/migrations/0003_drop_field_schemes.sql +9 -0
  64. package/migrations/meta/_journal.json +14 -0
  65. package/package.json +1 -1
  66. package/src/contract/models.ts +157 -33
  67. package/src/contract/router.ts +6 -27
  68. package/src/kql/fields.ts +5 -1
@@ -0,0 +1,172 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ /**
3
+ * A source that is allowed to produce an incomplete issue.
4
+ *
5
+ * A customer replying to a support address does not know the workspace made "Impact" required. If
6
+ * required fields were enforced on that path, the mail would bounce and the request would be lost —
7
+ * so inbound sources record the gap and carry on, and a human completes the issue afterwards.
8
+ */
9
+ const LENIENT_SOURCES = new Set(['email', 'intake', 'import', 'automation']);
10
+ const isBlank = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
11
+ const asArray = (v) => (Array.isArray(v) ? v : [v]);
12
+ /**
13
+ * Validates one value against its field definition. Pure: every lookup it needs is passed in, so
14
+ * it can be unit-tested and called in a loop without touching the database.
15
+ */
16
+ export function checkValue(def, value, ctx = {}) {
17
+ if (isBlank(value))
18
+ return null;
19
+ const cfg = def.config ?? {};
20
+ switch (def.type) {
21
+ case 'number':
22
+ case 'formula': {
23
+ if (typeof value !== 'number' || !Number.isFinite(value))
24
+ return 'Expected a number';
25
+ if (cfg.min !== undefined && value < cfg.min)
26
+ return `Must be at least ${cfg.min}`;
27
+ if (cfg.max !== undefined && value > cfg.max)
28
+ return `Must be at most ${cfg.max}`;
29
+ if (cfg.precision !== undefined) {
30
+ const scaled = value * 10 ** cfg.precision;
31
+ if (Math.abs(scaled - Math.round(scaled)) > 1e-9)
32
+ return cfg.precision === 0
33
+ ? 'Must be a whole number'
34
+ : `Must have at most ${cfg.precision} decimal places`;
35
+ }
36
+ return null;
37
+ }
38
+ case 'text':
39
+ case 'textarea': {
40
+ if (typeof value !== 'string')
41
+ return 'Expected text';
42
+ if (cfg.maxLength !== undefined && value.length > cfg.maxLength)
43
+ return `Must be ${cfg.maxLength} characters or fewer`;
44
+ if (cfg.pattern) {
45
+ // The pattern is validated when the field is saved, so a bad one cannot reach here. If one
46
+ // somehow does, refuse the value rather than throwing an unhandled SyntaxError on a write.
47
+ try {
48
+ if (!new RegExp(cfg.pattern).test(value))
49
+ return 'Does not match the required format';
50
+ }
51
+ catch {
52
+ return 'The field has an invalid pattern — ask an administrator to correct it';
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ case 'date':
58
+ case 'datetime': {
59
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)))
60
+ return 'Expected a date in ISO format';
61
+ return null;
62
+ }
63
+ case 'checkbox':
64
+ return typeof value === 'boolean' ? null : 'Expected true or false';
65
+ case 'url': {
66
+ if (typeof value !== 'string')
67
+ return 'Expected a URL';
68
+ let parsed;
69
+ try {
70
+ parsed = new URL(value);
71
+ }
72
+ catch {
73
+ return 'Expected a valid URL';
74
+ }
75
+ // Anything else — `javascript:`, `data:` — becomes a click target in the interface.
76
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
77
+ return 'Only http and https links are allowed';
78
+ return null;
79
+ }
80
+ case 'select':
81
+ case 'multiselect':
82
+ case 'label': {
83
+ const values = def.type === 'select' ? [value] : asArray(value);
84
+ const allowed = ctx.optionIds ?? new Set(def.options.filter((o) => !o.archived).map((o) => o.id));
85
+ for (const v of values) {
86
+ if (typeof v !== 'string')
87
+ return 'Expected an option id';
88
+ if (!allowed.has(v))
89
+ return 'Not one of the available options';
90
+ }
91
+ return null;
92
+ }
93
+ case 'user':
94
+ case 'multiuser': {
95
+ const values = def.type === 'user' ? [value] : asArray(value);
96
+ for (const v of values) {
97
+ if (typeof v !== 'string')
98
+ return 'Expected a user id';
99
+ if (ctx.memberIds && !ctx.memberIds.has(v))
100
+ return 'Not a member of this workspace';
101
+ }
102
+ return null;
103
+ }
104
+ case 'relation': {
105
+ // Always an array, whatever `relationMultiple` says — see the KQL compiler, which relies on it.
106
+ const values = asArray(value);
107
+ if (cfg.relationMultiple === false && values.length > 1)
108
+ return 'Only one item may be linked';
109
+ for (const v of values)
110
+ if (typeof v !== 'string')
111
+ return 'Expected an issue id';
112
+ return null;
113
+ }
114
+ default:
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Turns a submitted `custom` patch into the object to store.
120
+ *
121
+ * On create it starts empty and applies defaults; on update it merges into what is already there,
122
+ * where an explicit `null` deletes a key. Unknown keys are refused either way — silently dropping
123
+ * one makes a typo look like a field that does not save.
124
+ */
125
+ export async function normaliseCustom(args) {
126
+ const byKey = new Map(args.fields.map((f) => [f.key, f]));
127
+ const out = args.mode === 'create' ? {} : { ...(args.current ?? {}) };
128
+ const problems = [];
129
+ let members;
130
+ let membersLoaded = false;
131
+ for (const [key, value] of Object.entries(args.patch ?? {})) {
132
+ const def = byKey.get(key);
133
+ if (!def)
134
+ throw KernError.badRequest(`Unknown custom field "${key}"`, { field: `cf.${key}` });
135
+ if (value === null) {
136
+ delete out[key];
137
+ continue;
138
+ }
139
+ if ((def.type === 'user' || def.type === 'multiuser') && args.memberIds && !membersLoaded) {
140
+ members = await args.memberIds();
141
+ membersLoaded = true;
142
+ }
143
+ const problem = checkValue(def, value, { memberIds: members });
144
+ if (problem)
145
+ throw KernError.badRequest(problem, { field: `cf.${key}` });
146
+ out[key] = value;
147
+ }
148
+ if (args.mode === 'create')
149
+ for (const def of args.fields)
150
+ if (out[def.key] === undefined && def.defaultValue != null)
151
+ out[def.key] = def.defaultValue;
152
+ const lenient = LENIENT_SOURCES.has(args.source);
153
+ // On update, judge only the fields this patch touched. An issue created leniently — a customer
154
+ // email with no "Impact" — can still be edited; what cannot happen is *clearing* a required
155
+ // field, which touches it and so is caught.
156
+ const touched = new Set(Object.keys(args.patch ?? {}));
157
+ for (const def of args.fields) {
158
+ const fieldId = `cf.${def.key}`;
159
+ if (args.mode === 'update' && !touched.has(def.key))
160
+ continue;
161
+ const required = args.requiredFieldIds ? args.requiredFieldIds.has(fieldId) : def.required;
162
+ if (!required || !isBlank(out[def.key]))
163
+ continue;
164
+ const message = `"${def.name}" is required`;
165
+ if (lenient)
166
+ problems.push({ fieldId, message });
167
+ else
168
+ throw KernError.badRequest(message, { field: fieldId });
169
+ }
170
+ return { custom: out, skipped: problems };
171
+ }
172
+ //# sourceMappingURL=values.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"values.js","sourceRoot":"","sources":["../../../src/server/services/values.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ -- A custom field's key is the key it writes into `issues.custom`, so two field definitions that
2
+ -- share a key share a value — whatever their project scope. Until now two partial unique indexes
3
+ -- allowed exactly that: a workspace-level `severity` and a project-scoped `severity` could coexist
4
+ -- and then silently overwrite each other on every issue in that project.
5
+ --
6
+ -- The fix is one unique constraint over (workspace_id, key). Project scope still decides where a
7
+ -- field is *visible*; it no longer decides what it is *called*.
8
+ --
9
+ -- Refuse to run if the data already contains a collision. Merging two fields means choosing which
10
+ -- definition wins and what happens to the values under the loser, and that is not a decision a
11
+ -- migration may take on an operator's behalf.
12
+ do $$
13
+ declare
14
+ conflict text;
15
+ begin
16
+ select string_agg(format('%s (%s definitions)', key, n), ', ' order by key)
17
+ into conflict
18
+ from (
19
+ select key, count(*) as n
20
+ from "mod_tracker"."field_defs"
21
+ group by workspace_id, key
22
+ having count(*) > 1
23
+ ) dupes;
24
+
25
+ if conflict is not null then
26
+ raise exception
27
+ 'tracker: cannot apply 0002_field_keys_unique — duplicate field keys exist: %', conflict
28
+ using hint =
29
+ 'Rename or delete the duplicates so each key appears once per workspace, then migrate again.';
30
+ end if;
31
+ end $$;
32
+ --> statement-breakpoint
33
+ DROP INDEX IF EXISTS "mod_tracker"."field_defs_ws_project_key_uq";--> statement-breakpoint
34
+ DROP INDEX IF EXISTS "mod_tracker"."field_defs_ws_key_uq";--> statement-breakpoint
35
+ CREATE UNIQUE INDEX "field_defs_ws_key_uq" ON "mod_tracker"."field_defs" USING btree ("workspace_id","key");
@@ -0,0 +1,9 @@
1
+ -- Field schemes are gone. A project used to gate its custom fields twice: once through a field
2
+ -- scheme, and once through the per-work-item-type field layout that this release makes real. With
3
+ -- both in place a field disappears when *either* gate says so, and nobody can predict which one did
4
+ -- it. The layout is the more useful of the two — it also orders fields and marks them required — so
5
+ -- the scheme goes.
6
+ --
7
+ -- Type schemes and workflow schemes stay: they answer different questions.
8
+ ALTER TABLE "mod_tracker"."projects" DROP COLUMN IF EXISTS "field_scheme_id";--> statement-breakpoint
9
+ DROP TABLE IF EXISTS "mod_tracker"."field_schemes";
@@ -15,6 +15,20 @@
15
15
  "when": 1787397066443,
16
16
  "tag": "0001_rls",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1787397066444,
23
+ "tag": "0002_field_keys_unique",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1787397066445,
30
+ "tag": "0003_drop_field_schemes",
31
+ "breakpoints": true
18
32
  }
19
33
  ]
20
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-tracker",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Kern tracker module: projects, work item types, custom fields, workflows, issues, KQL, cycles, views, reports, intake, time tracking (server + client skeleton)",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -124,7 +124,6 @@ export const Project = z.object({
124
124
  defaultAssignee: z.enum(['unassigned', 'lead']),
125
125
  workflowSchemeId: Id.nullable(),
126
126
  typeSchemeId: Id.nullable(),
127
- fieldSchemeId: Id.nullable(),
128
127
  settings: ProjectSettings,
129
128
  /** public intake form token (null = intake disabled) */
130
129
  intakeToken: z.string().nullable(),
@@ -141,6 +140,14 @@ export const Project = z.object({
141
140
  })
142
141
  export type Project = z.infer<typeof Project>
143
142
 
143
+ /**
144
+ * The built-in project templates. `software`, `support`, `marketing` and `simple` are the four
145
+ * team shapes the tracker ships with; `kanban` and `blank` are kept because existing projects were
146
+ * created from them.
147
+ */
148
+ export const ProjectTemplateId = z.enum(['software', 'support', 'marketing', 'simple', 'kanban', 'blank'])
149
+ export type ProjectTemplateId = z.infer<typeof ProjectTemplateId>
150
+
144
151
  export const CreateProject = z.object({
145
152
  key: ProjectKey,
146
153
  name: z.string().min(1).max(120),
@@ -150,8 +157,8 @@ export const CreateProject = z.object({
150
157
  leadId: UserId.optional(),
151
158
  visibility: ProjectVisibility.default('workspace'),
152
159
  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'),
160
+ /** seed types/workflow/fields from a built-in template */
161
+ template: ProjectTemplateId.default('software'),
155
162
  /** or from a saved project template (overrides `template`) */
156
163
  templateId: Id.optional(),
157
164
  settings: ProjectSettings.partial().optional(),
@@ -169,7 +176,6 @@ export const UpdateProject = z.object({
169
176
  defaultAssignee: z.enum(['unassigned', 'lead']).optional(),
170
177
  workflowSchemeId: Id.nullable().optional(),
171
178
  typeSchemeId: Id.nullable().optional(),
172
- fieldSchemeId: Id.nullable().optional(),
173
179
  settings: ProjectSettings.partial().optional(),
174
180
  })
175
181
  export type UpdateProject = z.infer<typeof UpdateProject>
@@ -186,21 +192,6 @@ export const ProjectMember = z.object({
186
192
  })
187
193
  export type ProjectMember = z.infer<typeof ProjectMember>
188
194
 
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
195
  // =====================================================================================
205
196
  // work item types & hierarchy
206
197
  // =====================================================================================
@@ -209,16 +200,66 @@ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
209
200
  export const HierarchyLevel = z.number().int().min(-1).max(2)
210
201
  export type HierarchyLevel = z.infer<typeof HierarchyLevel>
211
202
 
203
+ /**
204
+ * The system fields a work item type may lay out, in their default order.
205
+ *
206
+ * `pinned` fields are always visible: an issue without a title, a status or a type is not an
207
+ * issue. The settings editor does not offer the control, and the resolver ignores a stored
208
+ * instruction that tries to hide one.
209
+ */
210
+ export const SYSTEM_LAYOUT_FIELDS = [
211
+ { id: 'title', section: 'main', pinned: true },
212
+ { id: 'description', section: 'main', pinned: false },
213
+ { id: 'status', section: 'sidebar', pinned: true },
214
+ { id: 'type', section: 'sidebar', pinned: true },
215
+ { id: 'assignees', section: 'sidebar', pinned: false },
216
+ { id: 'priority', section: 'sidebar', pinned: false },
217
+ { id: 'labels', section: 'sidebar', pinned: false },
218
+ { id: 'components', section: 'sidebar', pinned: false },
219
+ { id: 'versions', section: 'sidebar', pinned: false },
220
+ { id: 'estimate', section: 'sidebar', pinned: false },
221
+ { id: 'startDate', section: 'sidebar', pinned: false },
222
+ { id: 'dueDate', section: 'sidebar', pinned: false },
223
+ { id: 'cycle', section: 'sidebar', pinned: false },
224
+ { id: 'milestone', section: 'sidebar', pinned: false },
225
+ { id: 'parent', section: 'sidebar', pinned: false },
226
+ { id: 'reporter', section: 'sidebar', pinned: false },
227
+ ] as const satisfies ReadonlyArray<{
228
+ id: string
229
+ section: 'main' | 'sidebar'
230
+ pinned: boolean
231
+ }>
232
+
233
+ export const SystemFieldId = z.enum(SYSTEM_LAYOUT_FIELDS.map((f) => f.id) as [string, ...string[]])
234
+ export type SystemFieldId = (typeof SYSTEM_LAYOUT_FIELDS)[number]['id']
235
+
236
+ /** System field ids that may never be hidden, whatever a stored layout says. */
237
+ export const PINNED_FIELD_IDS: readonly string[] = SYSTEM_LAYOUT_FIELDS.filter((f) => f.pinned).map(
238
+ (f) => f.id,
239
+ )
240
+
241
+ /**
242
+ * A layout entry's `fieldId` is a *namespaced* name: a system field id (`priority`, `dueDate`) or
243
+ * `cf.<key>` for a custom field. That is the same string KQL, `ViewDisplay.columns` and workflow
244
+ * post-functions already use, so one resolver serves all four.
245
+ */
246
+ export const LayoutFieldId = z
247
+ .string()
248
+ .min(1)
249
+ .max(80)
250
+ .regex(/^(?:[a-zA-Z][a-zA-Z0-9_]*|cf\.[a-z][a-z0-9_]*)$/, 'Expected a system field id or `cf.<key>`')
251
+ export type LayoutFieldId = z.infer<typeof LayoutFieldId>
252
+
212
253
  export const FieldLayoutItem = z.object({
213
- /** system field name (`priority`, `dueDate`…) or custom field id */
214
- fieldId: z.string().min(1),
254
+ /** system field id (`priority`, `dueDate`…) or `cf.<key>` for a custom field */
255
+ fieldId: LayoutFieldId,
215
256
  section: z.enum(['main', 'sidebar', 'hidden']).default('sidebar'),
257
+ /** required on this type, over and above the field definition's own `required` */
216
258
  required: z.boolean().default(false),
217
259
  hidden: z.boolean().default(false),
218
260
  order: z.number().int().default(0),
219
261
  })
220
262
  export type FieldLayoutItem = z.infer<typeof FieldLayoutItem>
221
-
222
263
  export const WorkItemType = z.object({
223
264
  id: Id,
224
265
  workspaceId: WorkspaceId,
@@ -377,15 +418,37 @@ export const UpsertFieldDef = z.object({
377
418
  })
378
419
  export type UpsertFieldDef = z.infer<typeof UpsertFieldDef>
379
420
 
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,
421
+ /** One field as an interface should render it, system and custom fields alike. */
422
+ export const ResolvedField = z.object({
423
+ fieldId: LayoutFieldId,
424
+ /** `system` fields are rendered by a built-in component, `custom` ones by their field type */
425
+ kind: z.enum(['system', 'custom']),
426
+ label: z.string().min(1).max(120),
427
+ section: z.enum(['main', 'sidebar']),
428
+ order: z.number().int(),
429
+ required: z.boolean(),
430
+ pinned: z.boolean(),
431
+ showInCards: z.boolean(),
432
+ /** present when `kind` is `custom` — everything needed to render and validate the value */
433
+ field: FieldDef.nullable(),
434
+ })
435
+ export type ResolvedField = z.infer<typeof ResolvedField>
436
+
437
+ /**
438
+ * The fields of one work item type in one project, already ordered and merged.
439
+ *
440
+ * An **empty stored layout means the default layout** — everything visible. A field the stored
441
+ * layout does not name appends to `sidebar` rather than disappearing, so a newly created field
442
+ * shows up instead of looking broken.
443
+ */
444
+ export const ResolvedLayout = z.object({
445
+ typeId: Id,
446
+ projectId: Id.nullable(),
447
+ main: z.array(ResolvedField),
448
+ sidebar: z.array(ResolvedField),
449
+ hidden: z.array(ResolvedField),
387
450
  })
388
- export type FieldScheme = z.infer<typeof FieldScheme>
451
+ export type ResolvedLayout = z.infer<typeof ResolvedLayout>
389
452
 
390
453
  // =====================================================================================
391
454
  // workflows
@@ -1053,6 +1116,13 @@ export const GroupBy = z.enum([
1053
1116
  ])
1054
1117
  export type GroupBy = z.infer<typeof GroupBy>
1055
1118
 
1119
+ /**
1120
+ * A group key that may also name a custom field. `GroupBy` stays as it was so nothing that accepts
1121
+ * only the built-in keys has to change; views and `issues.query` accept this wider one.
1122
+ */
1123
+ export const GroupByValue = z.union([GroupBy, z.string().regex(/^cf\.[a-z][a-z0-9_]*$/)])
1124
+ export type GroupByValue = z.infer<typeof GroupByValue>
1125
+
1056
1126
  export const OrderBy = z.object({
1057
1127
  /** KQL field name (`priority`, `updated`, `rank`, `cf.severity`…) */
1058
1128
  field: z.string().min(1),
@@ -1071,7 +1141,7 @@ export const BoardColumn = z.object({
1071
1141
  export type BoardColumn = z.infer<typeof BoardColumn>
1072
1142
 
1073
1143
  export const ViewDisplay = z.object({
1074
- groupBy: GroupBy.default('none'),
1144
+ groupBy: GroupByValue.default('none'),
1075
1145
  subGroupBy: GroupBy.optional(),
1076
1146
  orderBy: z.array(OrderBy).default([{ field: 'rank', dir: 'asc' }]),
1077
1147
  /** visible columns (list/spreadsheet): system field names or `cf.<key>` */
@@ -1084,8 +1154,13 @@ export const ViewDisplay = z.object({
1084
1154
  /** board columns (null → one column per status of the project's workflows) */
1085
1155
  boardColumns: z.array(BoardColumn).nullable().default(null),
1086
1156
  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'),
1157
+ /** calendar: which date field positions issues — a system date field or `cf.<key>` */
1158
+ calendarField: z
1159
+ .union([
1160
+ z.enum(['dueDate', 'startDate', 'createdAt', 'updatedAt', 'resolvedAt']),
1161
+ z.string().regex(/^cf\.[a-z][a-z0-9_]*$/),
1162
+ ])
1163
+ .default('dueDate'),
1089
1164
  /** timeline: show dependency arrows */
1090
1165
  showDependencies: z.boolean().default(true),
1091
1166
  density: z.enum(['compact', 'comfortable']).default('comfortable'),
@@ -1420,3 +1495,52 @@ export const IssueApproval = z.object({
1420
1495
  updatedAt: Timestamp,
1421
1496
  })
1422
1497
  export type IssueApproval = z.infer<typeof IssueApproval>
1498
+
1499
+ // =====================================================================================
1500
+ // project templates
1501
+ // =====================================================================================
1502
+
1503
+ /**
1504
+ * Everything a template seeds into a new project. This is the *only* description of a template's
1505
+ * contents: the built-in four are values of this type in code, and a template saved from an
1506
+ * existing project snapshots into the same shape, so one applier serves both.
1507
+ *
1508
+ * Ids inside a body are template-local. Workflows are named by index, types name their workflow by
1509
+ * that index, and layouts name fields by `cf.<key>` — nothing here refers to a database id, which
1510
+ * is what lets a body created in one workspace apply in another.
1511
+ */
1512
+ export const ProjectTemplateBody = z.object({
1513
+ version: z.literal(1).default(1),
1514
+ settings: ProjectSettings.partial().optional(),
1515
+ workflows: z
1516
+ .array(z.object({ name: z.string().min(1).max(120), definition: WorkflowDefinition }))
1517
+ .default([]),
1518
+ fields: z.array(UpsertFieldDef).default([]),
1519
+ types: z
1520
+ .array(
1521
+ UpsertWorkItemType.omit({ workflowId: true }).extend({
1522
+ /** index into `workflows`; null → the project's default workflow */
1523
+ workflowIndex: z.number().int().nonnegative().nullable().default(null),
1524
+ }),
1525
+ )
1526
+ .default([]),
1527
+ labels: z
1528
+ .array(z.object({ name: z.string().min(1).max(60), color: Color.nullable().default(null) }))
1529
+ .default([]),
1530
+ views: z.array(UpsertView).default([]),
1531
+ })
1532
+ export type ProjectTemplateBody = z.infer<typeof ProjectTemplateBody>
1533
+
1534
+ /** Reusable project blueprint (types, workflow, fields, labels, sample views). */
1535
+ export const ProjectTemplate = z.object({
1536
+ id: Id,
1537
+ workspaceId: WorkspaceId.nullable(),
1538
+ key: MachineKey,
1539
+ name: z.string().min(1).max(120),
1540
+ description: z.string().max(1000).nullable(),
1541
+ icon: z.string().max(64).nullable(),
1542
+ body: ProjectTemplateBody,
1543
+ builtin: z.boolean(),
1544
+ createdAt: Timestamp,
1545
+ })
1546
+ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
@@ -16,7 +16,6 @@ import {
16
16
  Cycle,
17
17
  DateOnly,
18
18
  FieldDef,
19
- FieldScheme,
20
19
  HierarchyRules,
21
20
  ImportJob,
22
21
  ImportSource,
@@ -42,6 +41,7 @@ import {
42
41
  RecurringIssue,
43
42
  RelationType,
44
43
  RelationView,
44
+ ResolvedLayout,
45
45
  RichDoc,
46
46
  StatusHistoryEntry,
47
47
  StatusInfo,
@@ -173,6 +173,11 @@ export const trackerContract = {
173
173
  .route({ method: 'POST', path: '/types/{id}/archive', ...t('types') })
174
174
  .input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
175
175
  .output(WorkItemType),
176
+ /** the fields of one type in one project, ordered and merged — what a form should render */
177
+ layout: baseContract
178
+ .route({ method: 'GET', path: '/types/{id}/layout', ...t('types') })
179
+ .input(ws.extend({ id: Id, projectId: Id.nullable().optional() }))
180
+ .output(ResolvedLayout),
176
181
  hierarchyRules: baseContract
177
182
  .route({ method: 'GET', path: '/types/hierarchy-rules', ...t('types') })
178
183
  .input(ws)
@@ -238,32 +243,6 @@ export const trackerContract = {
238
243
  .route({ method: 'DELETE', path: '/fields/{id}', ...t('fields') })
239
244
  .input(ws.extend({ id: Id }))
240
245
  .output(Ok),
241
- schemes: {
242
- list: baseContract
243
- .route({ method: 'GET', path: '/field-schemes', ...t('fields') })
244
- .input(ws)
245
- .output(z.array(FieldScheme)),
246
- create: baseContract
247
- .route({ method: 'POST', path: '/field-schemes', ...t('fields') })
248
- .input(ws.extend({ name: z.string().min(1).max(120), fieldIds: z.array(Id) }))
249
- .output(FieldScheme),
250
- update: baseContract
251
- .route({ method: 'PATCH', path: '/field-schemes/{id}', ...t('fields') })
252
- .input(
253
- ws.extend({
254
- id: Id,
255
- patch: z.object({
256
- name: z.string().min(1).max(120).optional(),
257
- fieldIds: z.array(Id).optional(),
258
- }),
259
- }),
260
- )
261
- .output(FieldScheme),
262
- delete: baseContract
263
- .route({ method: 'DELETE', path: '/field-schemes/{id}', ...t('fields') })
264
- .input(ws.extend({ id: Id }))
265
- .output(Ok),
266
- },
267
246
  },
268
247
 
269
248
  // ------------------------------------------------------------------ workflows
package/src/kql/fields.ts CHANGED
@@ -143,7 +143,11 @@ export function customKqlField(key: string, fieldType: FieldType, label: string)
143
143
  name: `cf.${key}`,
144
144
  kind,
145
145
  label,
146
- array: fieldType === 'multiselect' || fieldType === 'multiuser' || fieldType === 'label',
146
+ array:
147
+ fieldType === 'multiselect' ||
148
+ fieldType === 'multiuser' ||
149
+ fieldType === 'label' ||
150
+ fieldType === 'relation',
147
151
  sortable: kind === 'number' || kind === 'date' || kind === 'datetime' || kind === 'text',
148
152
  custom: { key, fieldType },
149
153
  }