@kernhq/module-tracker 0.1.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/LICENSE +662 -0
- package/README.md +265 -0
- package/dist/contract/events.d.ts +140 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +51 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +6 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +6 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +2547 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +1214 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +4 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +46 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +161 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +192 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +9659 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +732 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/kql/ast.d.ts +90 -0
- package/dist/kql/ast.d.ts.map +1 -0
- package/dist/kql/ast.js +80 -0
- package/dist/kql/ast.js.map +1 -0
- package/dist/kql/dates.d.ts +16 -0
- package/dist/kql/dates.d.ts.map +1 -0
- package/dist/kql/dates.js +54 -0
- package/dist/kql/dates.js.map +1 -0
- package/dist/kql/fields.d.ts +61 -0
- package/dist/kql/fields.d.ts.map +1 -0
- package/dist/kql/fields.js +106 -0
- package/dist/kql/fields.js.map +1 -0
- package/dist/kql/index.d.ts +16 -0
- package/dist/kql/index.d.ts.map +1 -0
- package/dist/kql/index.js +16 -0
- package/dist/kql/index.js.map +1 -0
- package/dist/kql/lexer.d.ts +28 -0
- package/dist/kql/lexer.d.ts.map +1 -0
- package/dist/kql/lexer.js +127 -0
- package/dist/kql/lexer.js.map +1 -0
- package/dist/kql/parser.d.ts +14 -0
- package/dist/kql/parser.d.ts.map +1 -0
- package/dist/kql/parser.js +290 -0
- package/dist/kql/parser.js.map +1 -0
- package/dist/kql/suggest.d.ts +8 -0
- package/dist/kql/suggest.d.ts.map +1 -0
- package/dist/kql/suggest.js +96 -0
- package/dist/kql/suggest.js.map +1 -0
- package/dist/kql/validate.d.ts +10 -0
- package/dist/kql/validate.d.ts.map +1 -0
- package/dist/kql/validate.js +84 -0
- package/dist/kql/validate.js.map +1 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +583 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/kql/compile.d.ts +49 -0
- package/dist/server/kql/compile.d.ts.map +1 -0
- package/dist/server/kql/compile.js +372 -0
- package/dist/server/kql/compile.js.map +1 -0
- package/dist/server/rank.d.ts +23 -0
- package/dist/server/rank.d.ts.map +1 -0
- package/dist/server/rank.js +66 -0
- package/dist/server/rank.js.map +1 -0
- package/dist/server/rich.d.ts +30 -0
- package/dist/server/rich.d.ts.map +1 -0
- package/dist/server/rich.js +97 -0
- package/dist/server/rich.js.map +1 -0
- package/dist/server/router.d.ts +10589 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +431 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +6622 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +581 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/access.d.ts +53 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +146 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/comments.d.ts +36 -0
- package/dist/server/services/comments.d.ts.map +1 -0
- package/dist/server/services/comments.js +231 -0
- package/dist/server/services/comments.js.map +1 -0
- package/dist/server/services/config.d.ts +130 -0
- package/dist/server/services/config.d.ts.map +1 -0
- package/dist/server/services/config.js +797 -0
- package/dist/server/services/config.js.map +1 -0
- package/dist/server/services/db.d.ts +93 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +519 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/imports.d.ts +37 -0
- package/dist/server/services/imports.d.ts.map +1 -0
- package/dist/server/services/imports.js +324 -0
- package/dist/server/services/imports.js.map +1 -0
- package/dist/server/services/index.d.ts +36 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +57 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/intake.d.ts +47 -0
- package/dist/server/services/intake.d.ts.map +1 -0
- package/dist/server/services/intake.js +268 -0
- package/dist/server/services/intake.js.map +1 -0
- package/dist/server/services/issues.d.ts +763 -0
- package/dist/server/services/issues.d.ts.map +1 -0
- package/dist/server/services/issues.js +1381 -0
- package/dist/server/services/issues.js.map +1 -0
- package/dist/server/services/notify.d.ts +51 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +90 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/planning.d.ts +62 -0
- package/dist/server/services/planning.d.ts.map +1 -0
- package/dist/server/services/planning.js +614 -0
- package/dist/server/services/planning.js.map +1 -0
- package/dist/server/services/projects.d.ts +43 -0
- package/dist/server/services/projects.d.ts.map +1 -0
- package/dist/server/services/projects.js +375 -0
- package/dist/server/services/projects.js.map +1 -0
- package/dist/server/services/query.d.ts +24 -0
- package/dist/server/services/query.d.ts.map +1 -0
- package/dist/server/services/query.js +312 -0
- package/dist/server/services/query.js.map +1 -0
- package/dist/server/services/reports.d.ts +30 -0
- package/dist/server/services/reports.d.ts.map +1 -0
- package/dist/server/services/reports.js +295 -0
- package/dist/server/services/reports.js.map +1 -0
- package/dist/server/services/time.d.ts +53 -0
- package/dist/server/services/time.d.ts.map +1 -0
- package/dist/server/services/time.js +192 -0
- package/dist/server/services/time.js.map +1 -0
- package/dist/server/services/transitions.d.ts +61 -0
- package/dist/server/services/transitions.d.ts.map +1 -0
- package/dist/server/services/transitions.js +493 -0
- package/dist/server/services/transitions.js.map +1 -0
- package/dist/server/services/views.d.ts +33 -0
- package/dist/server/services/views.d.ts.map +1 -0
- package/dist/server/services/views.js +195 -0
- package/dist/server/services/views.js.map +1 -0
- package/migrations/0000_init.sql +529 -0
- package/migrations/0001_rls.sql +259 -0
- package/migrations/meta/0000_snapshot.json +4140 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +82 -0
- package/src/client/api.ts +15 -0
- package/src/client/format.ts +164 -0
- package/src/client/group.ts +150 -0
- package/src/client/index.ts +60 -0
- package/src/client/kql.ts +492 -0
- package/src/client/rank.ts +87 -0
- package/src/client/types.ts +57 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { builtinRegistry, createWorkflowFromTemplate, sortedStatuses, validateDefinition, workflowTemplates, } from '@kernhq/workflow';
|
|
3
|
+
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
4
|
+
import { HierarchyRules, } from '../../contract/models.js';
|
|
5
|
+
import { fieldDefs, fieldSchemes, hierarchyRules, issues, projects, typeSchemes, workflowSchemes, workflows, workItemTypes, } from '../schema.js';
|
|
6
|
+
import { toFieldDef, toFieldScheme, toTypeScheme, toWorkflow, toWorkflowScheme, toWorkItemType, } from './db.js';
|
|
7
|
+
const DEFAULT_HIERARCHY = HierarchyRules.parse({});
|
|
8
|
+
let validationRegistry = null;
|
|
9
|
+
/** Rule registry used to check that a definition only references rules the tracker can execute. */
|
|
10
|
+
const registryForValidation = () => {
|
|
11
|
+
validationRegistry ??= builtinRegistry();
|
|
12
|
+
return validationRegistry;
|
|
13
|
+
};
|
|
14
|
+
/** Types seeded into a fresh project, per project template. */
|
|
15
|
+
const TYPE_SEEDS = {
|
|
16
|
+
software: [
|
|
17
|
+
{ key: 'epic', name: 'Epic', level: 1, icon: 'zap' },
|
|
18
|
+
{ key: 'story', name: 'Story', level: 0, icon: 'bookmark' },
|
|
19
|
+
{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' },
|
|
20
|
+
{ key: 'bug', name: 'Bug', level: 0, icon: 'bug' },
|
|
21
|
+
{ key: 'sub_task', name: 'Sub-task', level: -1, icon: 'git-branch' },
|
|
22
|
+
],
|
|
23
|
+
kanban: [
|
|
24
|
+
{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' },
|
|
25
|
+
{ key: 'bug', name: 'Bug', level: 0, icon: 'bug' },
|
|
26
|
+
],
|
|
27
|
+
simple: [{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' }],
|
|
28
|
+
blank: [{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' }],
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_TYPE_KEY = {
|
|
31
|
+
software: 'story',
|
|
32
|
+
kanban: 'task',
|
|
33
|
+
simple: 'task',
|
|
34
|
+
blank: 'task',
|
|
35
|
+
};
|
|
36
|
+
const WORKFLOW_FOR_TEMPLATE = {
|
|
37
|
+
software: 'software',
|
|
38
|
+
kanban: 'kanban',
|
|
39
|
+
simple: 'simple',
|
|
40
|
+
blank: 'simple',
|
|
41
|
+
};
|
|
42
|
+
/** Work item types, custom fields, workflows and the schemes that bind them to projects. */
|
|
43
|
+
export class ConfigService {
|
|
44
|
+
kernel;
|
|
45
|
+
access;
|
|
46
|
+
notify;
|
|
47
|
+
constructor(kernel, access, notify) {
|
|
48
|
+
this.kernel = kernel;
|
|
49
|
+
this.access = access;
|
|
50
|
+
this.notify = notify;
|
|
51
|
+
}
|
|
52
|
+
/** Configuration is cached hard in the client, so every change has to be announced. */
|
|
53
|
+
announce(workspaceId, entity, id, op) {
|
|
54
|
+
return this.notify.change(workspaceId, entity, id, op);
|
|
55
|
+
}
|
|
56
|
+
// ------------------------------------------------------------------ work item types
|
|
57
|
+
async listTypes(tx, workspaceId, opts = {}) {
|
|
58
|
+
const filters = [eq(workItemTypes.workspaceId, workspaceId)];
|
|
59
|
+
if (!opts.includeArchived)
|
|
60
|
+
filters.push(isNull(workItemTypes.archivedAt));
|
|
61
|
+
if (opts.projectId)
|
|
62
|
+
filters.push(or(isNull(workItemTypes.projectId), eq(workItemTypes.projectId, opts.projectId)));
|
|
63
|
+
const rows = await tx
|
|
64
|
+
.select()
|
|
65
|
+
.from(workItemTypes)
|
|
66
|
+
.where(and(...filters))
|
|
67
|
+
.orderBy(asc(workItemTypes.order), asc(workItemTypes.name));
|
|
68
|
+
if (!opts.projectId)
|
|
69
|
+
return rows;
|
|
70
|
+
const scheme = await this.typeSchemeFor(tx, workspaceId, opts.projectId);
|
|
71
|
+
if (!scheme?.typeIds.length)
|
|
72
|
+
return rows;
|
|
73
|
+
const allowed = new Set(scheme.typeIds);
|
|
74
|
+
return rows.filter((r) => allowed.has(r.id) || r.projectId === opts.projectId);
|
|
75
|
+
}
|
|
76
|
+
async typeSchemeFor(tx, workspaceId, projectId) {
|
|
77
|
+
const [project] = await tx
|
|
78
|
+
.select({ typeSchemeId: projects.typeSchemeId })
|
|
79
|
+
.from(projects)
|
|
80
|
+
.where(and(eq(projects.workspaceId, workspaceId), eq(projects.id, projectId)))
|
|
81
|
+
.limit(1);
|
|
82
|
+
if (!project?.typeSchemeId)
|
|
83
|
+
return null;
|
|
84
|
+
const [row] = await tx.select().from(typeSchemes).where(eq(typeSchemes.id, project.typeSchemeId)).limit(1);
|
|
85
|
+
return row ? toTypeScheme(row) : null;
|
|
86
|
+
}
|
|
87
|
+
async getType(tx, workspaceId, id) {
|
|
88
|
+
const [row] = await tx
|
|
89
|
+
.select()
|
|
90
|
+
.from(workItemTypes)
|
|
91
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
|
|
92
|
+
.limit(1);
|
|
93
|
+
if (!row)
|
|
94
|
+
throw KernError.notFound('Work item type');
|
|
95
|
+
return row;
|
|
96
|
+
}
|
|
97
|
+
/** The type new issues get when the caller does not name one. */
|
|
98
|
+
async defaultType(tx, workspaceId, projectId) {
|
|
99
|
+
const scheme = await this.typeSchemeFor(tx, workspaceId, projectId);
|
|
100
|
+
const rows = await this.listTypes(tx, workspaceId, { projectId });
|
|
101
|
+
const usable = rows.filter((r) => r.level >= 0);
|
|
102
|
+
const preferred = (scheme?.defaultTypeId ? usable.find((r) => r.id === scheme.defaultTypeId) : undefined) ??
|
|
103
|
+
usable.find((r) => r.isDefault && r.projectId === projectId) ??
|
|
104
|
+
usable.find((r) => r.isDefault) ??
|
|
105
|
+
usable.find((r) => r.projectId === projectId) ??
|
|
106
|
+
usable[0];
|
|
107
|
+
if (!preferred)
|
|
108
|
+
throw KernError.badRequest('This project has no work item types');
|
|
109
|
+
return preferred;
|
|
110
|
+
}
|
|
111
|
+
async createType(tx, workspaceId, input, actorId) {
|
|
112
|
+
const id = uuidv7();
|
|
113
|
+
await this.assertTypeKeyFree(tx, workspaceId, input.projectId ?? null, input.key);
|
|
114
|
+
if (input.isDefault)
|
|
115
|
+
await this.clearDefaultType(tx, workspaceId, input.projectId ?? null);
|
|
116
|
+
const [row] = await tx
|
|
117
|
+
.insert(workItemTypes)
|
|
118
|
+
.values({
|
|
119
|
+
id,
|
|
120
|
+
workspaceId,
|
|
121
|
+
projectId: input.projectId ?? null,
|
|
122
|
+
key: input.key,
|
|
123
|
+
name: input.name,
|
|
124
|
+
description: input.description ?? null,
|
|
125
|
+
icon: input.icon ?? null,
|
|
126
|
+
color: input.color ?? null,
|
|
127
|
+
level: input.level,
|
|
128
|
+
isDefault: input.isDefault ?? false,
|
|
129
|
+
workflowId: input.workflowId ?? null,
|
|
130
|
+
fieldLayout: input.fieldLayout ?? [],
|
|
131
|
+
templateBody: input.templateBody ?? null,
|
|
132
|
+
order: input.order ?? 0,
|
|
133
|
+
})
|
|
134
|
+
.returning();
|
|
135
|
+
void actorId;
|
|
136
|
+
await this.announce(workspaceId, 'type', row.id, 'created');
|
|
137
|
+
return toWorkItemType(row);
|
|
138
|
+
}
|
|
139
|
+
async updateType(tx, workspaceId, id, patch) {
|
|
140
|
+
const current = await this.getType(tx, workspaceId, id);
|
|
141
|
+
if (patch.key && patch.key !== current.key)
|
|
142
|
+
await this.assertTypeKeyFree(tx, workspaceId, patch.projectId ?? current.projectId, patch.key);
|
|
143
|
+
if (patch.isDefault)
|
|
144
|
+
await this.clearDefaultType(tx, workspaceId, current.projectId);
|
|
145
|
+
const [row] = await tx
|
|
146
|
+
.update(workItemTypes)
|
|
147
|
+
.set({
|
|
148
|
+
...(patch.key === undefined ? {} : { key: patch.key }),
|
|
149
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
150
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
151
|
+
...(patch.icon === undefined ? {} : { icon: patch.icon }),
|
|
152
|
+
...(patch.color === undefined ? {} : { color: patch.color }),
|
|
153
|
+
...(patch.level === undefined ? {} : { level: patch.level }),
|
|
154
|
+
...(patch.isDefault === undefined ? {} : { isDefault: patch.isDefault }),
|
|
155
|
+
...(patch.workflowId === undefined ? {} : { workflowId: patch.workflowId }),
|
|
156
|
+
...(patch.fieldLayout === undefined ? {} : { fieldLayout: patch.fieldLayout }),
|
|
157
|
+
...(patch.templateBody === undefined ? {} : { templateBody: patch.templateBody }),
|
|
158
|
+
...(patch.order === undefined ? {} : { order: patch.order }),
|
|
159
|
+
updatedAt: new Date(),
|
|
160
|
+
})
|
|
161
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
|
|
162
|
+
.returning();
|
|
163
|
+
await this.announce(workspaceId, 'type', id, 'updated');
|
|
164
|
+
return toWorkItemType(row);
|
|
165
|
+
}
|
|
166
|
+
async archiveType(tx, workspaceId, id, archived) {
|
|
167
|
+
if (archived) {
|
|
168
|
+
const [used] = await tx
|
|
169
|
+
.select({ n: sql `count(*)::int` })
|
|
170
|
+
.from(issues)
|
|
171
|
+
.where(and(eq(issues.workspaceId, workspaceId), eq(issues.typeId, id), isNull(issues.archivedAt)));
|
|
172
|
+
if ((used?.n ?? 0) > 0)
|
|
173
|
+
throw KernError.conflict(`${used.n} open issues still use this type`, 'tracker.type.in_use');
|
|
174
|
+
}
|
|
175
|
+
const [row] = await tx
|
|
176
|
+
.update(workItemTypes)
|
|
177
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
178
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
|
|
179
|
+
.returning();
|
|
180
|
+
if (!row)
|
|
181
|
+
throw KernError.notFound('Work item type');
|
|
182
|
+
await this.announce(workspaceId, 'type', id, 'updated');
|
|
183
|
+
return toWorkItemType(row);
|
|
184
|
+
}
|
|
185
|
+
async assertTypeKeyFree(tx, workspaceId, projectId, key) {
|
|
186
|
+
const [clash] = await tx
|
|
187
|
+
.select({ id: workItemTypes.id })
|
|
188
|
+
.from(workItemTypes)
|
|
189
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.key, key), projectId ? eq(workItemTypes.projectId, projectId) : isNull(workItemTypes.projectId)))
|
|
190
|
+
.limit(1);
|
|
191
|
+
if (clash)
|
|
192
|
+
throw KernError.conflict(`Type key "${key}" is already used`, 'tracker.type.key_taken');
|
|
193
|
+
}
|
|
194
|
+
async clearDefaultType(tx, workspaceId, projectId) {
|
|
195
|
+
await tx
|
|
196
|
+
.update(workItemTypes)
|
|
197
|
+
.set({ isDefault: false })
|
|
198
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), projectId ? eq(workItemTypes.projectId, projectId) : isNull(workItemTypes.projectId)));
|
|
199
|
+
}
|
|
200
|
+
async hierarchyRules(tx, workspaceId) {
|
|
201
|
+
const [row] = await tx.select().from(hierarchyRules).where(eq(hierarchyRules.workspaceId, workspaceId));
|
|
202
|
+
return row ? HierarchyRules.parse(row.rules ?? {}) : DEFAULT_HIERARCHY;
|
|
203
|
+
}
|
|
204
|
+
async setHierarchyRules(tx, workspaceId, rules) {
|
|
205
|
+
await tx
|
|
206
|
+
.insert(hierarchyRules)
|
|
207
|
+
.values({ workspaceId, rules })
|
|
208
|
+
.onConflictDoUpdate({ target: hierarchyRules.workspaceId, set: { rules, updatedAt: new Date() } });
|
|
209
|
+
await this.announce(workspaceId, 'hierarchy_rules', workspaceId, 'updated');
|
|
210
|
+
return rules;
|
|
211
|
+
}
|
|
212
|
+
// ------------------------------------------------------------------ type schemes
|
|
213
|
+
async listTypeSchemes(tx, workspaceId) {
|
|
214
|
+
const rows = await tx
|
|
215
|
+
.select()
|
|
216
|
+
.from(typeSchemes)
|
|
217
|
+
.where(eq(typeSchemes.workspaceId, workspaceId))
|
|
218
|
+
.orderBy(asc(typeSchemes.createdAt));
|
|
219
|
+
return rows.map(toTypeScheme);
|
|
220
|
+
}
|
|
221
|
+
async createTypeScheme(tx, workspaceId, input) {
|
|
222
|
+
const [row] = await tx
|
|
223
|
+
.insert(typeSchemes)
|
|
224
|
+
.values({
|
|
225
|
+
id: uuidv7(),
|
|
226
|
+
workspaceId,
|
|
227
|
+
name: input.name,
|
|
228
|
+
typeIds: input.typeIds,
|
|
229
|
+
defaultTypeId: input.defaultTypeId ?? null,
|
|
230
|
+
})
|
|
231
|
+
.returning();
|
|
232
|
+
await this.announce(workspaceId, 'type_scheme', row.id, 'created');
|
|
233
|
+
return toTypeScheme(row);
|
|
234
|
+
}
|
|
235
|
+
async updateTypeScheme(tx, workspaceId, id, patch) {
|
|
236
|
+
const [row] = await tx
|
|
237
|
+
.update(typeSchemes)
|
|
238
|
+
.set({
|
|
239
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
240
|
+
...(patch.typeIds === undefined ? {} : { typeIds: patch.typeIds }),
|
|
241
|
+
...(patch.defaultTypeId === undefined ? {} : { defaultTypeId: patch.defaultTypeId }),
|
|
242
|
+
})
|
|
243
|
+
.where(and(eq(typeSchemes.workspaceId, workspaceId), eq(typeSchemes.id, id)))
|
|
244
|
+
.returning();
|
|
245
|
+
if (!row)
|
|
246
|
+
throw KernError.notFound('Type scheme');
|
|
247
|
+
await this.announce(workspaceId, 'type_scheme', id, 'updated');
|
|
248
|
+
return toTypeScheme(row);
|
|
249
|
+
}
|
|
250
|
+
async deleteTypeScheme(tx, workspaceId, id) {
|
|
251
|
+
await tx.update(projects).set({ typeSchemeId: null }).where(eq(projects.typeSchemeId, id));
|
|
252
|
+
await tx.delete(typeSchemes).where(and(eq(typeSchemes.workspaceId, workspaceId), eq(typeSchemes.id, id)));
|
|
253
|
+
await this.announce(workspaceId, 'type_scheme', id, 'deleted');
|
|
254
|
+
}
|
|
255
|
+
// ------------------------------------------------------------------ custom fields
|
|
256
|
+
async listFieldRows(tx, workspaceId, opts = {}) {
|
|
257
|
+
const filters = [eq(fieldDefs.workspaceId, workspaceId)];
|
|
258
|
+
if (!opts.includeArchived)
|
|
259
|
+
filters.push(isNull(fieldDefs.archivedAt));
|
|
260
|
+
if (opts.projectId)
|
|
261
|
+
filters.push(or(isNull(fieldDefs.projectId), eq(fieldDefs.projectId, opts.projectId)));
|
|
262
|
+
const rows = await tx
|
|
263
|
+
.select()
|
|
264
|
+
.from(fieldDefs)
|
|
265
|
+
.where(and(...filters))
|
|
266
|
+
.orderBy(asc(fieldDefs.order), asc(fieldDefs.name));
|
|
267
|
+
if (!opts.projectId)
|
|
268
|
+
return rows;
|
|
269
|
+
const [project] = await tx
|
|
270
|
+
.select({ fieldSchemeId: projects.fieldSchemeId })
|
|
271
|
+
.from(projects)
|
|
272
|
+
.where(eq(projects.id, opts.projectId))
|
|
273
|
+
.limit(1);
|
|
274
|
+
if (!project?.fieldSchemeId)
|
|
275
|
+
return rows;
|
|
276
|
+
const [scheme] = await tx.select().from(fieldSchemes).where(eq(fieldSchemes.id, project.fieldSchemeId));
|
|
277
|
+
if (!scheme?.fieldIds?.length)
|
|
278
|
+
return rows;
|
|
279
|
+
const allowed = new Set(scheme.fieldIds);
|
|
280
|
+
return rows.filter((r) => allowed.has(r.id) || r.projectId === opts.projectId);
|
|
281
|
+
}
|
|
282
|
+
async listFields(tx, workspaceId, opts = {}) {
|
|
283
|
+
return (await this.listFieldRows(tx, workspaceId, opts)).map(toFieldDef);
|
|
284
|
+
}
|
|
285
|
+
/** Fields visible across a set of projects (KQL autocomplete over a multi-project view). */
|
|
286
|
+
async fieldsForProjects(tx, workspaceId, projectIds) {
|
|
287
|
+
if (!projectIds.length)
|
|
288
|
+
return this.listFields(tx, workspaceId);
|
|
289
|
+
const seen = new Map();
|
|
290
|
+
for (const projectId of projectIds)
|
|
291
|
+
for (const f of await this.listFields(tx, workspaceId, { projectId }))
|
|
292
|
+
seen.set(f.id, f);
|
|
293
|
+
return [...seen.values()];
|
|
294
|
+
}
|
|
295
|
+
async createField(tx, workspaceId, input) {
|
|
296
|
+
const [clash] = await tx
|
|
297
|
+
.select({ id: fieldDefs.id })
|
|
298
|
+
.from(fieldDefs)
|
|
299
|
+
.where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.key, input.key), input.projectId ? eq(fieldDefs.projectId, input.projectId) : isNull(fieldDefs.projectId)))
|
|
300
|
+
.limit(1);
|
|
301
|
+
if (clash)
|
|
302
|
+
throw KernError.conflict(`Field key "${input.key}" is already used`, 'tracker.field.key_taken');
|
|
303
|
+
const [row] = await tx
|
|
304
|
+
.insert(fieldDefs)
|
|
305
|
+
.values({
|
|
306
|
+
id: uuidv7(),
|
|
307
|
+
workspaceId,
|
|
308
|
+
projectId: input.projectId ?? null,
|
|
309
|
+
key: input.key,
|
|
310
|
+
name: input.name,
|
|
311
|
+
description: input.description ?? null,
|
|
312
|
+
type: input.type,
|
|
313
|
+
options: input.options ?? [],
|
|
314
|
+
defaultValue: input.defaultValue ?? null,
|
|
315
|
+
config: input.config ?? {},
|
|
316
|
+
searchable: input.searchable ?? false,
|
|
317
|
+
required: input.required ?? false,
|
|
318
|
+
showInCards: input.showInCards ?? false,
|
|
319
|
+
order: input.order ?? 0,
|
|
320
|
+
})
|
|
321
|
+
.returning();
|
|
322
|
+
await this.announce(workspaceId, 'field', row.id, 'created');
|
|
323
|
+
return toFieldDef(row);
|
|
324
|
+
}
|
|
325
|
+
async updateField(tx, workspaceId, id, patch) {
|
|
326
|
+
const [row] = await tx
|
|
327
|
+
.update(fieldDefs)
|
|
328
|
+
.set({
|
|
329
|
+
...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
|
|
330
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
331
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
332
|
+
...(patch.options === undefined ? {} : { options: patch.options }),
|
|
333
|
+
...(patch.defaultValue === undefined ? {} : { defaultValue: patch.defaultValue }),
|
|
334
|
+
...(patch.config === undefined ? {} : { config: patch.config }),
|
|
335
|
+
...(patch.searchable === undefined ? {} : { searchable: patch.searchable }),
|
|
336
|
+
...(patch.required === undefined ? {} : { required: patch.required }),
|
|
337
|
+
...(patch.showInCards === undefined ? {} : { showInCards: patch.showInCards }),
|
|
338
|
+
...(patch.order === undefined ? {} : { order: patch.order }),
|
|
339
|
+
updatedAt: new Date(),
|
|
340
|
+
})
|
|
341
|
+
.where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
|
|
342
|
+
.returning();
|
|
343
|
+
if (!row)
|
|
344
|
+
throw KernError.notFound('Field');
|
|
345
|
+
await this.announce(workspaceId, 'field', id, 'updated');
|
|
346
|
+
return toFieldDef(row);
|
|
347
|
+
}
|
|
348
|
+
async archiveField(tx, workspaceId, id, archived) {
|
|
349
|
+
const [row] = await tx
|
|
350
|
+
.update(fieldDefs)
|
|
351
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
352
|
+
.where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
|
|
353
|
+
.returning();
|
|
354
|
+
if (!row)
|
|
355
|
+
throw KernError.notFound('Field');
|
|
356
|
+
await this.announce(workspaceId, 'field', id, 'updated');
|
|
357
|
+
return toFieldDef(row);
|
|
358
|
+
}
|
|
359
|
+
/** Deleting a field also strips its values from every issue: the key would otherwise be unreachable. */
|
|
360
|
+
async deleteField(tx, workspaceId, id) {
|
|
361
|
+
const [row] = await tx
|
|
362
|
+
.select()
|
|
363
|
+
.from(fieldDefs)
|
|
364
|
+
.where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
|
|
365
|
+
.limit(1);
|
|
366
|
+
if (!row)
|
|
367
|
+
throw KernError.notFound('Field');
|
|
368
|
+
await tx
|
|
369
|
+
.update(issues)
|
|
370
|
+
.set({ custom: sql `${issues.custom} - ${row.key}` })
|
|
371
|
+
.where(and(eq(issues.workspaceId, workspaceId), sql `${issues.custom} ? ${row.key}`));
|
|
372
|
+
await tx.delete(fieldDefs).where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)));
|
|
373
|
+
await this.announce(workspaceId, 'field', id, 'deleted');
|
|
374
|
+
}
|
|
375
|
+
async listFieldSchemes(tx, workspaceId) {
|
|
376
|
+
const rows = await tx
|
|
377
|
+
.select()
|
|
378
|
+
.from(fieldSchemes)
|
|
379
|
+
.where(eq(fieldSchemes.workspaceId, workspaceId))
|
|
380
|
+
.orderBy(asc(fieldSchemes.createdAt));
|
|
381
|
+
return rows.map(toFieldScheme);
|
|
382
|
+
}
|
|
383
|
+
async createFieldScheme(tx, workspaceId, input) {
|
|
384
|
+
const [row] = await tx
|
|
385
|
+
.insert(fieldSchemes)
|
|
386
|
+
.values({ id: uuidv7(), workspaceId, name: input.name, fieldIds: input.fieldIds })
|
|
387
|
+
.returning();
|
|
388
|
+
await this.announce(workspaceId, 'field_scheme', row.id, 'created');
|
|
389
|
+
return toFieldScheme(row);
|
|
390
|
+
}
|
|
391
|
+
async updateFieldScheme(tx, workspaceId, id, patch) {
|
|
392
|
+
const [row] = await tx
|
|
393
|
+
.update(fieldSchemes)
|
|
394
|
+
.set({
|
|
395
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
396
|
+
...(patch.fieldIds === undefined ? {} : { fieldIds: patch.fieldIds }),
|
|
397
|
+
})
|
|
398
|
+
.where(and(eq(fieldSchemes.workspaceId, workspaceId), eq(fieldSchemes.id, id)))
|
|
399
|
+
.returning();
|
|
400
|
+
if (!row)
|
|
401
|
+
throw KernError.notFound('Field scheme');
|
|
402
|
+
await this.announce(workspaceId, 'field_scheme', id, 'updated');
|
|
403
|
+
return toFieldScheme(row);
|
|
404
|
+
}
|
|
405
|
+
async deleteFieldScheme(tx, workspaceId, id) {
|
|
406
|
+
await tx.update(projects).set({ fieldSchemeId: null }).where(eq(projects.fieldSchemeId, id));
|
|
407
|
+
await tx
|
|
408
|
+
.delete(fieldSchemes)
|
|
409
|
+
.where(and(eq(fieldSchemes.workspaceId, workspaceId), eq(fieldSchemes.id, id)));
|
|
410
|
+
await this.announce(workspaceId, 'field_scheme', id, 'deleted');
|
|
411
|
+
}
|
|
412
|
+
// ------------------------------------------------------------------ workflows
|
|
413
|
+
async listWorkflows(tx, workspaceId, opts = {}) {
|
|
414
|
+
const filters = [eq(workflows.workspaceId, workspaceId)];
|
|
415
|
+
if (!opts.includeArchived)
|
|
416
|
+
filters.push(isNull(workflows.archivedAt));
|
|
417
|
+
if (opts.projectId)
|
|
418
|
+
filters.push(or(isNull(workflows.projectId), eq(workflows.projectId, opts.projectId)));
|
|
419
|
+
const rows = await tx
|
|
420
|
+
.select()
|
|
421
|
+
.from(workflows)
|
|
422
|
+
.where(and(...filters))
|
|
423
|
+
.orderBy(asc(workflows.name));
|
|
424
|
+
const usage = new Map();
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
const [n] = await tx
|
|
427
|
+
.select({ n: sql `count(*)::int` })
|
|
428
|
+
.from(workItemTypes)
|
|
429
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.workflowId, row.id)));
|
|
430
|
+
usage.set(row.id, n?.n ?? 0);
|
|
431
|
+
}
|
|
432
|
+
return rows.map((r) => toWorkflow(r, usage.get(r.id) ?? 0));
|
|
433
|
+
}
|
|
434
|
+
async getWorkflowRow(tx, workspaceId, id) {
|
|
435
|
+
const [row] = await tx
|
|
436
|
+
.select()
|
|
437
|
+
.from(workflows)
|
|
438
|
+
.where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
|
|
439
|
+
.limit(1);
|
|
440
|
+
if (!row)
|
|
441
|
+
throw KernError.notFound('Workflow');
|
|
442
|
+
return row;
|
|
443
|
+
}
|
|
444
|
+
async createWorkflow(tx, workspaceId, input) {
|
|
445
|
+
const validated = validateDefinition(input.definition);
|
|
446
|
+
if (!validated.ok)
|
|
447
|
+
throw KernError.badRequest('Invalid workflow definition', { problems: validated.problems });
|
|
448
|
+
if (input.isDefault)
|
|
449
|
+
await this.clearDefaultWorkflow(tx, workspaceId, input.projectId ?? null);
|
|
450
|
+
const [row] = await tx
|
|
451
|
+
.insert(workflows)
|
|
452
|
+
.values({
|
|
453
|
+
id: uuidv7(),
|
|
454
|
+
workspaceId,
|
|
455
|
+
projectId: input.projectId ?? null,
|
|
456
|
+
name: input.name,
|
|
457
|
+
description: input.description ?? null,
|
|
458
|
+
definition: validated.definition,
|
|
459
|
+
isDefault: input.isDefault ?? false,
|
|
460
|
+
})
|
|
461
|
+
.returning();
|
|
462
|
+
await this.announce(workspaceId, 'workflow', row.id, 'created');
|
|
463
|
+
return toWorkflow(row, 0);
|
|
464
|
+
}
|
|
465
|
+
async updateWorkflow(tx, workspaceId, id, patch) {
|
|
466
|
+
const current = await this.getWorkflowRow(tx, workspaceId, id);
|
|
467
|
+
let definition = current.definition;
|
|
468
|
+
if (patch.definition) {
|
|
469
|
+
const validated = validateDefinition(patch.definition);
|
|
470
|
+
if (!validated.ok)
|
|
471
|
+
throw KernError.badRequest('Invalid workflow definition', { problems: validated.problems });
|
|
472
|
+
definition = validated.definition;
|
|
473
|
+
await this.assertStatusesStillUsed(tx, workspaceId, id, definition);
|
|
474
|
+
}
|
|
475
|
+
if (patch.isDefault)
|
|
476
|
+
await this.clearDefaultWorkflow(tx, workspaceId, current.projectId);
|
|
477
|
+
const [row] = await tx
|
|
478
|
+
.update(workflows)
|
|
479
|
+
.set({
|
|
480
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
481
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
482
|
+
...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
|
|
483
|
+
...(patch.isDefault === undefined ? {} : { isDefault: patch.isDefault }),
|
|
484
|
+
definition,
|
|
485
|
+
updatedAt: new Date(),
|
|
486
|
+
})
|
|
487
|
+
.where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
|
|
488
|
+
.returning();
|
|
489
|
+
await this.announce(workspaceId, 'workflow', id, 'updated');
|
|
490
|
+
return toWorkflow(row);
|
|
491
|
+
}
|
|
492
|
+
/** Refuse to drop a status that issues still sit in — they would become unreachable. */
|
|
493
|
+
async assertStatusesStillUsed(tx, workspaceId, workflowId, next) {
|
|
494
|
+
const known = new Set(next.statuses.map((s) => s.id));
|
|
495
|
+
const rows = await tx
|
|
496
|
+
.select({ statusId: issues.statusId, n: sql `count(*)::int` })
|
|
497
|
+
.from(issues)
|
|
498
|
+
.where(and(eq(issues.workspaceId, workspaceId), sql `true`))
|
|
499
|
+
.groupBy(issues.statusId);
|
|
500
|
+
const typeRows = await tx
|
|
501
|
+
.select({ id: workItemTypes.id })
|
|
502
|
+
.from(workItemTypes)
|
|
503
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.workflowId, workflowId)));
|
|
504
|
+
if (!typeRows.length)
|
|
505
|
+
return;
|
|
506
|
+
const missing = rows.filter((r) => !known.has(r.statusId) && r.n > 0).map((r) => r.statusId);
|
|
507
|
+
if (missing.length)
|
|
508
|
+
throw KernError.conflict(`Statuses still in use: ${missing.join(', ')}`, 'tracker.workflow.status_in_use');
|
|
509
|
+
}
|
|
510
|
+
async clearDefaultWorkflow(tx, workspaceId, projectId) {
|
|
511
|
+
await tx
|
|
512
|
+
.update(workflows)
|
|
513
|
+
.set({ isDefault: false })
|
|
514
|
+
.where(and(eq(workflows.workspaceId, workspaceId), projectId ? eq(workflows.projectId, projectId) : isNull(workflows.projectId)));
|
|
515
|
+
}
|
|
516
|
+
async archiveWorkflow(tx, workspaceId, id, archived) {
|
|
517
|
+
const [row] = await tx
|
|
518
|
+
.update(workflows)
|
|
519
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
520
|
+
.where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
|
|
521
|
+
.returning();
|
|
522
|
+
if (!row)
|
|
523
|
+
throw KernError.notFound('Workflow');
|
|
524
|
+
await this.announce(workspaceId, 'workflow', id, 'updated');
|
|
525
|
+
return toWorkflow(row);
|
|
526
|
+
}
|
|
527
|
+
/** Row → contract shape, for the router. */
|
|
528
|
+
toContractType(row) {
|
|
529
|
+
return toWorkItemType(row);
|
|
530
|
+
}
|
|
531
|
+
toContractWorkflow(row) {
|
|
532
|
+
return toWorkflow(row);
|
|
533
|
+
}
|
|
534
|
+
/** Structural validation of a definition without saving it. */
|
|
535
|
+
validate(definition) {
|
|
536
|
+
const result = validateDefinition(definition, registryForValidation());
|
|
537
|
+
return result.ok ? { ok: true, problems: [] } : { ok: false, problems: result.problems };
|
|
538
|
+
}
|
|
539
|
+
workflowTemplates() {
|
|
540
|
+
return Object.keys(workflowTemplates).map((id) => {
|
|
541
|
+
const definition = createWorkflowFromTemplate(id, { id });
|
|
542
|
+
return { id, name: definition.name, definition };
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
// ------------------------------------------------------------------ workflow schemes
|
|
546
|
+
async listWorkflowSchemes(tx, workspaceId) {
|
|
547
|
+
const rows = await tx
|
|
548
|
+
.select()
|
|
549
|
+
.from(workflowSchemes)
|
|
550
|
+
.where(eq(workflowSchemes.workspaceId, workspaceId))
|
|
551
|
+
.orderBy(asc(workflowSchemes.createdAt));
|
|
552
|
+
return rows.map(toWorkflowScheme);
|
|
553
|
+
}
|
|
554
|
+
async createWorkflowScheme(tx, workspaceId, input) {
|
|
555
|
+
const [row] = await tx
|
|
556
|
+
.insert(workflowSchemes)
|
|
557
|
+
.values({
|
|
558
|
+
id: uuidv7(),
|
|
559
|
+
workspaceId,
|
|
560
|
+
name: input.name,
|
|
561
|
+
defaultWorkflowId: input.defaultWorkflowId,
|
|
562
|
+
mappings: input.mappings,
|
|
563
|
+
})
|
|
564
|
+
.returning();
|
|
565
|
+
await this.announce(workspaceId, 'workflow_scheme', row.id, 'created');
|
|
566
|
+
return toWorkflowScheme(row);
|
|
567
|
+
}
|
|
568
|
+
async updateWorkflowScheme(tx, workspaceId, id, patch) {
|
|
569
|
+
const [row] = await tx
|
|
570
|
+
.update(workflowSchemes)
|
|
571
|
+
.set({
|
|
572
|
+
...(patch.name === undefined ? {} : { name: patch.name }),
|
|
573
|
+
...(patch.defaultWorkflowId === undefined ? {} : { defaultWorkflowId: patch.defaultWorkflowId }),
|
|
574
|
+
...(patch.mappings === undefined ? {} : { mappings: patch.mappings }),
|
|
575
|
+
updatedAt: new Date(),
|
|
576
|
+
})
|
|
577
|
+
.where(and(eq(workflowSchemes.workspaceId, workspaceId), eq(workflowSchemes.id, id)))
|
|
578
|
+
.returning();
|
|
579
|
+
if (!row)
|
|
580
|
+
throw KernError.notFound('Workflow scheme');
|
|
581
|
+
await this.announce(workspaceId, 'workflow_scheme', id, 'updated');
|
|
582
|
+
return toWorkflowScheme(row);
|
|
583
|
+
}
|
|
584
|
+
async deleteWorkflowScheme(tx, workspaceId, id) {
|
|
585
|
+
await tx.update(projects).set({ workflowSchemeId: null }).where(eq(projects.workflowSchemeId, id));
|
|
586
|
+
await tx
|
|
587
|
+
.delete(workflowSchemes)
|
|
588
|
+
.where(and(eq(workflowSchemes.workspaceId, workspaceId), eq(workflowSchemes.id, id)));
|
|
589
|
+
await this.announce(workspaceId, 'workflow_scheme', id, 'deleted');
|
|
590
|
+
}
|
|
591
|
+
// ------------------------------------------------------------------ resolution
|
|
592
|
+
/**
|
|
593
|
+
* The workflow an issue of `typeId` follows in `project`:
|
|
594
|
+
* explicit type workflow → project's workflow scheme → project default → workspace default.
|
|
595
|
+
*/
|
|
596
|
+
async workflowFor(tx, project, typeId) {
|
|
597
|
+
const workspaceId = project.workspaceId;
|
|
598
|
+
const type = await this.getType(tx, workspaceId, typeId).catch(() => null);
|
|
599
|
+
if (type?.workflowId) {
|
|
600
|
+
const row = await this.getWorkflowRow(tx, workspaceId, type.workflowId).catch(() => null);
|
|
601
|
+
if (row)
|
|
602
|
+
return { workflowId: row.id, definition: row.definition };
|
|
603
|
+
}
|
|
604
|
+
if (project.workflowSchemeId) {
|
|
605
|
+
const [scheme] = await tx
|
|
606
|
+
.select()
|
|
607
|
+
.from(workflowSchemes)
|
|
608
|
+
.where(eq(workflowSchemes.id, project.workflowSchemeId))
|
|
609
|
+
.limit(1);
|
|
610
|
+
if (scheme) {
|
|
611
|
+
const mappings = scheme.mappings ?? [];
|
|
612
|
+
const mapped = mappings.find((m) => m.typeId === typeId)?.workflowId ?? scheme.defaultWorkflowId;
|
|
613
|
+
const row = await this.getWorkflowRow(tx, workspaceId, mapped).catch(() => null);
|
|
614
|
+
if (row)
|
|
615
|
+
return { workflowId: row.id, definition: row.definition };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const candidates = await tx
|
|
619
|
+
.select()
|
|
620
|
+
.from(workflows)
|
|
621
|
+
.where(and(eq(workflows.workspaceId, workspaceId), isNull(workflows.archivedAt), or(isNull(workflows.projectId), eq(workflows.projectId, project.id))))
|
|
622
|
+
.orderBy(asc(workflows.createdAt));
|
|
623
|
+
const chosen = candidates.find((w) => w.projectId === project.id && w.isDefault) ??
|
|
624
|
+
candidates.find((w) => w.projectId === project.id) ??
|
|
625
|
+
candidates.find((w) => w.isDefault) ??
|
|
626
|
+
candidates[0];
|
|
627
|
+
if (!chosen)
|
|
628
|
+
throw KernError.badRequest('This project has no workflow');
|
|
629
|
+
return { workflowId: chosen.id, definition: chosen.definition };
|
|
630
|
+
}
|
|
631
|
+
/** Every status of every workflow the project can use, deduplicated for board columns and KQL. */
|
|
632
|
+
async statusesForProject(tx, workspaceId, projectId) {
|
|
633
|
+
const filters = [eq(workflows.workspaceId, workspaceId), isNull(workflows.archivedAt)];
|
|
634
|
+
if (projectId)
|
|
635
|
+
filters.push(or(isNull(workflows.projectId), eq(workflows.projectId, projectId)));
|
|
636
|
+
const rows = await tx
|
|
637
|
+
.select()
|
|
638
|
+
.from(workflows)
|
|
639
|
+
.where(and(...filters));
|
|
640
|
+
const seen = new Map();
|
|
641
|
+
for (const row of rows) {
|
|
642
|
+
const definition = row.definition;
|
|
643
|
+
for (const [index, status] of sortedStatuses(definition).entries()) {
|
|
644
|
+
if (seen.has(status.id))
|
|
645
|
+
continue;
|
|
646
|
+
seen.set(status.id, {
|
|
647
|
+
id: status.id,
|
|
648
|
+
name: status.name,
|
|
649
|
+
category: status.category,
|
|
650
|
+
color: status.color ?? null,
|
|
651
|
+
order: status.order ?? index,
|
|
652
|
+
workflowId: row.id,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return [...seen.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
657
|
+
}
|
|
658
|
+
// ------------------------------------------------------------------ seeding
|
|
659
|
+
/** Create the workflow and types a brand-new project starts with. */
|
|
660
|
+
async seedProject(tx, workspaceId, projectId, template) {
|
|
661
|
+
const templateId = WORKFLOW_FOR_TEMPLATE[template] ?? 'simple';
|
|
662
|
+
const workflowId = uuidv7();
|
|
663
|
+
const definition = createWorkflowFromTemplate(templateId, { id: workflowId });
|
|
664
|
+
await tx.insert(workflows).values({
|
|
665
|
+
id: workflowId,
|
|
666
|
+
workspaceId,
|
|
667
|
+
projectId,
|
|
668
|
+
name: definition.name,
|
|
669
|
+
description: definition.description ?? null,
|
|
670
|
+
definition,
|
|
671
|
+
isDefault: true,
|
|
672
|
+
});
|
|
673
|
+
const seeds = TYPE_SEEDS[template] ?? TYPE_SEEDS.simple;
|
|
674
|
+
const defaultKey = DEFAULT_TYPE_KEY[template] ?? 'task';
|
|
675
|
+
const typeIds = [];
|
|
676
|
+
let defaultTypeId = '';
|
|
677
|
+
for (const [order, seed] of seeds.entries()) {
|
|
678
|
+
const id = uuidv7();
|
|
679
|
+
typeIds.push(id);
|
|
680
|
+
if (seed.key === defaultKey)
|
|
681
|
+
defaultTypeId = id;
|
|
682
|
+
await tx.insert(workItemTypes).values({
|
|
683
|
+
id,
|
|
684
|
+
workspaceId,
|
|
685
|
+
projectId,
|
|
686
|
+
key: seed.key,
|
|
687
|
+
name: seed.name,
|
|
688
|
+
icon: seed.icon,
|
|
689
|
+
level: seed.level,
|
|
690
|
+
isDefault: seed.key === defaultKey,
|
|
691
|
+
workflowId,
|
|
692
|
+
order,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return { workflowId, typeIds, defaultTypeId: defaultTypeId || typeIds[0] };
|
|
696
|
+
}
|
|
697
|
+
/** Restore a project blueprint saved with `projects.templates.saveFromProject`. */
|
|
698
|
+
async applyProjectTemplateBody(tx, workspaceId, projectId, body) {
|
|
699
|
+
const definitions = body.workflows ?? [];
|
|
700
|
+
const types = body.types ?? [];
|
|
701
|
+
if (!definitions.length || !types.length)
|
|
702
|
+
return null;
|
|
703
|
+
const workflowIds = [];
|
|
704
|
+
for (const definition of definitions) {
|
|
705
|
+
const id = uuidv7();
|
|
706
|
+
workflowIds.push(id);
|
|
707
|
+
const validated = validateDefinition({ ...definition, id });
|
|
708
|
+
if (!validated.ok)
|
|
709
|
+
continue;
|
|
710
|
+
await tx.insert(workflows).values({
|
|
711
|
+
id,
|
|
712
|
+
workspaceId,
|
|
713
|
+
projectId,
|
|
714
|
+
name: definition.name,
|
|
715
|
+
definition: validated.definition,
|
|
716
|
+
isDefault: workflowIds.length === 1,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
let defaultTypeId = '';
|
|
720
|
+
for (const [order, type] of types.entries()) {
|
|
721
|
+
const id = uuidv7();
|
|
722
|
+
if (type.isDefault || !defaultTypeId)
|
|
723
|
+
defaultTypeId = id;
|
|
724
|
+
await tx.insert(workItemTypes).values({
|
|
725
|
+
id,
|
|
726
|
+
workspaceId,
|
|
727
|
+
projectId,
|
|
728
|
+
key: type.key,
|
|
729
|
+
name: type.name,
|
|
730
|
+
icon: type.icon ?? null,
|
|
731
|
+
color: type.color ?? null,
|
|
732
|
+
level: type.level,
|
|
733
|
+
isDefault: !!type.isDefault,
|
|
734
|
+
workflowId: workflowIds[type.workflowIndex ?? 0] ?? workflowIds[0],
|
|
735
|
+
order,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return { workflowId: workflowIds[0], defaultTypeId };
|
|
739
|
+
}
|
|
740
|
+
/** Snapshot a project's configuration so another project can be created from it. */
|
|
741
|
+
async snapshotProject(tx, workspaceId, projectId) {
|
|
742
|
+
const typeRows = await tx
|
|
743
|
+
.select()
|
|
744
|
+
.from(workItemTypes)
|
|
745
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.projectId, projectId)))
|
|
746
|
+
.orderBy(asc(workItemTypes.order));
|
|
747
|
+
const workflowRows = await tx
|
|
748
|
+
.select()
|
|
749
|
+
.from(workflows)
|
|
750
|
+
.where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.projectId, projectId)));
|
|
751
|
+
const workflowIndex = new Map(workflowRows.map((w, i) => [w.id, i]));
|
|
752
|
+
const fieldRows = await tx
|
|
753
|
+
.select()
|
|
754
|
+
.from(fieldDefs)
|
|
755
|
+
.where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.projectId, projectId)));
|
|
756
|
+
return {
|
|
757
|
+
workflows: workflowRows.map((w) => w.definition),
|
|
758
|
+
types: typeRows.map((t) => ({
|
|
759
|
+
key: t.key,
|
|
760
|
+
name: t.name,
|
|
761
|
+
level: t.level,
|
|
762
|
+
icon: t.icon,
|
|
763
|
+
color: t.color,
|
|
764
|
+
isDefault: t.isDefault,
|
|
765
|
+
workflowIndex: t.workflowId ? (workflowIndex.get(t.workflowId) ?? 0) : 0,
|
|
766
|
+
})),
|
|
767
|
+
fields: fieldRows.map((f) => ({
|
|
768
|
+
key: f.key,
|
|
769
|
+
name: f.name,
|
|
770
|
+
type: f.type,
|
|
771
|
+
options: f.options,
|
|
772
|
+
config: f.config,
|
|
773
|
+
})),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/** Ensure a set of type ids all belong to this workspace (scheme validation). */
|
|
777
|
+
async assertTypesExist(tx, workspaceId, ids) {
|
|
778
|
+
if (!ids.length)
|
|
779
|
+
return;
|
|
780
|
+
const rows = await tx
|
|
781
|
+
.select({ id: workItemTypes.id })
|
|
782
|
+
.from(workItemTypes)
|
|
783
|
+
.where(and(eq(workItemTypes.workspaceId, workspaceId), inArray(workItemTypes.id, ids)));
|
|
784
|
+
if (rows.length !== new Set(ids).size)
|
|
785
|
+
throw KernError.badRequest('Unknown work item type in scheme');
|
|
786
|
+
}
|
|
787
|
+
/** Permission gate shared by every configuration mutation. */
|
|
788
|
+
async requireWorkspacePermission(principal, workspaceId, permission) {
|
|
789
|
+
void this.access;
|
|
790
|
+
await this.kernel.authz.require(principal, permission, {
|
|
791
|
+
kind: 'workspace',
|
|
792
|
+
id: workspaceId,
|
|
793
|
+
workspaceId,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=config.js.map
|