@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.
Files changed (158) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +265 -0
  3. package/dist/contract/events.d.ts +140 -0
  4. package/dist/contract/events.d.ts.map +1 -0
  5. package/dist/contract/events.js +51 -0
  6. package/dist/contract/events.js.map +1 -0
  7. package/dist/contract/index.d.ts +6 -0
  8. package/dist/contract/index.d.ts.map +1 -0
  9. package/dist/contract/index.js +6 -0
  10. package/dist/contract/index.js.map +1 -0
  11. package/dist/contract/models.d.ts +2547 -0
  12. package/dist/contract/models.d.ts.map +1 -0
  13. package/dist/contract/models.js +1214 -0
  14. package/dist/contract/models.js.map +1 -0
  15. package/dist/contract/notifications.d.ts +4 -0
  16. package/dist/contract/notifications.d.ts.map +1 -0
  17. package/dist/contract/notifications.js +46 -0
  18. package/dist/contract/notifications.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +161 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +192 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +9659 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +732 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/kql/ast.d.ts +90 -0
  28. package/dist/kql/ast.d.ts.map +1 -0
  29. package/dist/kql/ast.js +80 -0
  30. package/dist/kql/ast.js.map +1 -0
  31. package/dist/kql/dates.d.ts +16 -0
  32. package/dist/kql/dates.d.ts.map +1 -0
  33. package/dist/kql/dates.js +54 -0
  34. package/dist/kql/dates.js.map +1 -0
  35. package/dist/kql/fields.d.ts +61 -0
  36. package/dist/kql/fields.d.ts.map +1 -0
  37. package/dist/kql/fields.js +106 -0
  38. package/dist/kql/fields.js.map +1 -0
  39. package/dist/kql/index.d.ts +16 -0
  40. package/dist/kql/index.d.ts.map +1 -0
  41. package/dist/kql/index.js +16 -0
  42. package/dist/kql/index.js.map +1 -0
  43. package/dist/kql/lexer.d.ts +28 -0
  44. package/dist/kql/lexer.d.ts.map +1 -0
  45. package/dist/kql/lexer.js +127 -0
  46. package/dist/kql/lexer.js.map +1 -0
  47. package/dist/kql/parser.d.ts +14 -0
  48. package/dist/kql/parser.d.ts.map +1 -0
  49. package/dist/kql/parser.js +290 -0
  50. package/dist/kql/parser.js.map +1 -0
  51. package/dist/kql/suggest.d.ts +8 -0
  52. package/dist/kql/suggest.d.ts.map +1 -0
  53. package/dist/kql/suggest.js +96 -0
  54. package/dist/kql/suggest.js.map +1 -0
  55. package/dist/kql/validate.d.ts +10 -0
  56. package/dist/kql/validate.d.ts.map +1 -0
  57. package/dist/kql/validate.js +84 -0
  58. package/dist/kql/validate.js.map +1 -0
  59. package/dist/server/index.d.ts +17 -0
  60. package/dist/server/index.d.ts.map +1 -0
  61. package/dist/server/index.js +583 -0
  62. package/dist/server/index.js.map +1 -0
  63. package/dist/server/kql/compile.d.ts +49 -0
  64. package/dist/server/kql/compile.d.ts.map +1 -0
  65. package/dist/server/kql/compile.js +372 -0
  66. package/dist/server/kql/compile.js.map +1 -0
  67. package/dist/server/rank.d.ts +23 -0
  68. package/dist/server/rank.d.ts.map +1 -0
  69. package/dist/server/rank.js +66 -0
  70. package/dist/server/rank.js.map +1 -0
  71. package/dist/server/rich.d.ts +30 -0
  72. package/dist/server/rich.d.ts.map +1 -0
  73. package/dist/server/rich.js +97 -0
  74. package/dist/server/rich.js.map +1 -0
  75. package/dist/server/router.d.ts +10589 -0
  76. package/dist/server/router.d.ts.map +1 -0
  77. package/dist/server/router.js +431 -0
  78. package/dist/server/router.js.map +1 -0
  79. package/dist/server/schema.d.ts +6622 -0
  80. package/dist/server/schema.d.ts.map +1 -0
  81. package/dist/server/schema.js +581 -0
  82. package/dist/server/schema.js.map +1 -0
  83. package/dist/server/services/access.d.ts +53 -0
  84. package/dist/server/services/access.d.ts.map +1 -0
  85. package/dist/server/services/access.js +146 -0
  86. package/dist/server/services/access.js.map +1 -0
  87. package/dist/server/services/comments.d.ts +36 -0
  88. package/dist/server/services/comments.d.ts.map +1 -0
  89. package/dist/server/services/comments.js +231 -0
  90. package/dist/server/services/comments.js.map +1 -0
  91. package/dist/server/services/config.d.ts +130 -0
  92. package/dist/server/services/config.d.ts.map +1 -0
  93. package/dist/server/services/config.js +797 -0
  94. package/dist/server/services/config.js.map +1 -0
  95. package/dist/server/services/db.d.ts +93 -0
  96. package/dist/server/services/db.d.ts.map +1 -0
  97. package/dist/server/services/db.js +519 -0
  98. package/dist/server/services/db.js.map +1 -0
  99. package/dist/server/services/imports.d.ts +37 -0
  100. package/dist/server/services/imports.d.ts.map +1 -0
  101. package/dist/server/services/imports.js +324 -0
  102. package/dist/server/services/imports.js.map +1 -0
  103. package/dist/server/services/index.d.ts +36 -0
  104. package/dist/server/services/index.d.ts.map +1 -0
  105. package/dist/server/services/index.js +57 -0
  106. package/dist/server/services/index.js.map +1 -0
  107. package/dist/server/services/intake.d.ts +47 -0
  108. package/dist/server/services/intake.d.ts.map +1 -0
  109. package/dist/server/services/intake.js +268 -0
  110. package/dist/server/services/intake.js.map +1 -0
  111. package/dist/server/services/issues.d.ts +763 -0
  112. package/dist/server/services/issues.d.ts.map +1 -0
  113. package/dist/server/services/issues.js +1381 -0
  114. package/dist/server/services/issues.js.map +1 -0
  115. package/dist/server/services/notify.d.ts +51 -0
  116. package/dist/server/services/notify.d.ts.map +1 -0
  117. package/dist/server/services/notify.js +90 -0
  118. package/dist/server/services/notify.js.map +1 -0
  119. package/dist/server/services/planning.d.ts +62 -0
  120. package/dist/server/services/planning.d.ts.map +1 -0
  121. package/dist/server/services/planning.js +614 -0
  122. package/dist/server/services/planning.js.map +1 -0
  123. package/dist/server/services/projects.d.ts +43 -0
  124. package/dist/server/services/projects.d.ts.map +1 -0
  125. package/dist/server/services/projects.js +375 -0
  126. package/dist/server/services/projects.js.map +1 -0
  127. package/dist/server/services/query.d.ts +24 -0
  128. package/dist/server/services/query.d.ts.map +1 -0
  129. package/dist/server/services/query.js +312 -0
  130. package/dist/server/services/query.js.map +1 -0
  131. package/dist/server/services/reports.d.ts +30 -0
  132. package/dist/server/services/reports.d.ts.map +1 -0
  133. package/dist/server/services/reports.js +295 -0
  134. package/dist/server/services/reports.js.map +1 -0
  135. package/dist/server/services/time.d.ts +53 -0
  136. package/dist/server/services/time.d.ts.map +1 -0
  137. package/dist/server/services/time.js +192 -0
  138. package/dist/server/services/time.js.map +1 -0
  139. package/dist/server/services/transitions.d.ts +61 -0
  140. package/dist/server/services/transitions.d.ts.map +1 -0
  141. package/dist/server/services/transitions.js +493 -0
  142. package/dist/server/services/transitions.js.map +1 -0
  143. package/dist/server/services/views.d.ts +33 -0
  144. package/dist/server/services/views.d.ts.map +1 -0
  145. package/dist/server/services/views.js +195 -0
  146. package/dist/server/services/views.js.map +1 -0
  147. package/migrations/0000_init.sql +529 -0
  148. package/migrations/0001_rls.sql +259 -0
  149. package/migrations/meta/0000_snapshot.json +4140 -0
  150. package/migrations/meta/_journal.json +20 -0
  151. package/package.json +82 -0
  152. package/src/client/api.ts +15 -0
  153. package/src/client/format.ts +164 -0
  154. package/src/client/group.ts +150 -0
  155. package/src/client/index.ts +60 -0
  156. package/src/client/kql.ts +492 -0
  157. package/src/client/rank.ts +87 -0
  158. package/src/client/types.ts +57 -0
@@ -0,0 +1,614 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
3
+ import { trackerEvents } from '../../contract/events.js';
4
+ import { components, cycles, issueCounters, issues, labels, milestones, versions } from '../schema.js';
5
+ import { EMPTY_CYCLE_STATS, toComponent, toCycle, toLabel, toMilestone, toVersion, } from './db.js';
6
+ const RESOLVED = ['done', 'cancelled'];
7
+ export class PlanningService {
8
+ kernel;
9
+ access;
10
+ notify;
11
+ constructor(kernel, access, notify) {
12
+ this.kernel = kernel;
13
+ this.access = access;
14
+ this.notify = notify;
15
+ }
16
+ // ------------------------------------------------------------------ cycles
17
+ async cycleStats(tx, workspaceId, cycleIds) {
18
+ const out = new Map();
19
+ for (const id of cycleIds)
20
+ out.set(id, { ...EMPTY_CYCLE_STATS });
21
+ if (!cycleIds.length)
22
+ return out;
23
+ const rows = await tx
24
+ .select({
25
+ cycleId: issues.cycleId,
26
+ total: sql `count(*)::int`,
27
+ done: sql `count(*) filter (where ${issues.statusCategory} in ('done','cancelled'))::int`,
28
+ estimateTotal: sql `coalesce(sum(${issues.estimate}), 0)::float8`,
29
+ estimateDone: sql `coalesce(sum(${issues.estimate}) filter (where ${issues.statusCategory} in ('done','cancelled')), 0)::float8`,
30
+ })
31
+ .from(issues)
32
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(issues.cycleId, cycleIds), isNull(issues.archivedAt)))
33
+ .groupBy(issues.cycleId);
34
+ for (const row of rows)
35
+ if (row.cycleId)
36
+ out.set(row.cycleId, {
37
+ total: Number(row.total),
38
+ done: Number(row.done),
39
+ estimateTotal: Number(row.estimateTotal),
40
+ estimateDone: Number(row.estimateDone),
41
+ });
42
+ return out;
43
+ }
44
+ async listCycles(tx, principal, workspaceId, projectId, status) {
45
+ await this.access.requireProject(tx, principal, workspaceId, projectId);
46
+ const filters = [eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, projectId)];
47
+ if (status)
48
+ filters.push(eq(cycles.status, status));
49
+ const rows = await tx
50
+ .select()
51
+ .from(cycles)
52
+ .where(and(...filters))
53
+ .orderBy(asc(cycles.number));
54
+ const stats = await this.cycleStats(tx, workspaceId, rows.map((r) => r.id));
55
+ return rows.map((r) => toCycle(r, stats.get(r.id)));
56
+ }
57
+ async getCycle(tx, principal, workspaceId, id) {
58
+ const row = await this.cycleRow(tx, workspaceId, id);
59
+ await this.access.requireProject(tx, principal, workspaceId, row.projectId);
60
+ const stats = await this.cycleStats(tx, workspaceId, [id]);
61
+ return toCycle(row, stats.get(id));
62
+ }
63
+ async cycleRow(tx, workspaceId, id) {
64
+ const [row] = await tx
65
+ .select()
66
+ .from(cycles)
67
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
68
+ .limit(1);
69
+ if (!row)
70
+ throw KernError.notFound('Cycle');
71
+ return row;
72
+ }
73
+ async createCycle(tx, principal, workspaceId, projectId, input) {
74
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.cycle.manage');
75
+ if (new Date(input.endAt) <= new Date(input.startAt))
76
+ throw KernError.badRequest('A cycle must end after it starts');
77
+ await tx
78
+ .insert(issueCounters)
79
+ .values({ projectId, workspaceId })
80
+ .onConflictDoNothing({ target: issueCounters.projectId });
81
+ const [counter] = await tx
82
+ .update(issueCounters)
83
+ .set({ lastCycleNumber: sql `${issueCounters.lastCycleNumber} + 1` })
84
+ .where(and(eq(issueCounters.workspaceId, workspaceId), eq(issueCounters.projectId, projectId)))
85
+ .returning({ n: issueCounters.lastCycleNumber });
86
+ const number = counter?.n ?? 1;
87
+ const [row] = await tx
88
+ .insert(cycles)
89
+ .values({
90
+ id: uuidv7(),
91
+ workspaceId,
92
+ projectId,
93
+ number,
94
+ name: input.name ?? `Cycle ${number}`,
95
+ goal: input.goal ?? null,
96
+ startAt: new Date(input.startAt),
97
+ endAt: new Date(input.endAt),
98
+ status: 'upcoming',
99
+ })
100
+ .returning();
101
+ await this.notify.change(workspaceId, 'cycle', row.id, 'created', { scope: { projectId } });
102
+ return toCycle(row);
103
+ }
104
+ async updateCycle(tx, principal, workspaceId, id, patch) {
105
+ const current = await this.cycleRow(tx, workspaceId, id);
106
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
107
+ const [row] = await tx
108
+ .update(cycles)
109
+ .set({
110
+ ...(patch.name === undefined ? {} : { name: patch.name }),
111
+ ...(patch.goal === undefined ? {} : { goal: patch.goal }),
112
+ ...(patch.startAt === undefined ? {} : { startAt: new Date(patch.startAt) }),
113
+ ...(patch.endAt === undefined ? {} : { endAt: new Date(patch.endAt) }),
114
+ updatedAt: new Date(),
115
+ })
116
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
117
+ .returning();
118
+ const stats = await this.cycleStats(tx, workspaceId, [id]);
119
+ await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
120
+ return toCycle(row, stats.get(id));
121
+ }
122
+ async deleteCycle(tx, principal, workspaceId, id) {
123
+ const current = await this.cycleRow(tx, workspaceId, id);
124
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
125
+ await tx
126
+ .update(issues)
127
+ .set({ cycleId: null })
128
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.cycleId, id)));
129
+ await tx.delete(cycles).where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)));
130
+ await this.notify.change(workspaceId, 'cycle', id, 'deleted', { scope: { projectId: current.projectId } });
131
+ }
132
+ /** Starting a cycle snapshots its scope so velocity can compare committed against completed. */
133
+ async startCycle(tx, principal, workspaceId, id) {
134
+ const current = await this.cycleRow(tx, workspaceId, id);
135
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
136
+ if (current.status === 'completed')
137
+ throw KernError.conflict('Cycle is already completed');
138
+ const [running] = await tx
139
+ .select({ id: cycles.id })
140
+ .from(cycles)
141
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, current.projectId), eq(cycles.status, 'active')))
142
+ .limit(1);
143
+ if (running && running.id !== id)
144
+ throw KernError.conflict('Another cycle is already active in this project', 'tracker.cycle.active');
145
+ const stats = (await this.cycleStats(tx, workspaceId, [id])).get(id) ?? EMPTY_CYCLE_STATS;
146
+ const [row] = await tx
147
+ .update(cycles)
148
+ .set({
149
+ status: 'active',
150
+ startedAt: new Date(),
151
+ committed: { total: stats.total, estimateTotal: stats.estimateTotal },
152
+ updatedAt: new Date(),
153
+ })
154
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
155
+ .returning();
156
+ await this.kernel.emit(trackerEvents.cycleStarted, {
157
+ workspaceId: workspaceId,
158
+ projectId: current.projectId,
159
+ cycleId: id,
160
+ number: current.number,
161
+ }, { workspaceId, actorId: principal.userId });
162
+ await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
163
+ return toCycle(row, stats);
164
+ }
165
+ /** Complete a cycle; unfinished work rolls into `rollToCycleId`, the next upcoming cycle, or the backlog. */
166
+ async completeCycle(tx, principal, workspaceId, id, rollToCycleId) {
167
+ const current = await this.cycleRow(tx, workspaceId, id);
168
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
169
+ if (current.status === 'completed')
170
+ return this.getCycle(tx, principal, workspaceId, id);
171
+ let target = rollToCycleId ?? null;
172
+ if (target === undefined || target === null) {
173
+ const [next] = await tx
174
+ .select({ id: cycles.id })
175
+ .from(cycles)
176
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.projectId, current.projectId), eq(cycles.status, 'upcoming')))
177
+ .orderBy(asc(cycles.startAt))
178
+ .limit(1);
179
+ target = next?.id ?? null;
180
+ }
181
+ const unfinished = await tx
182
+ .select({ id: issues.id })
183
+ .from(issues)
184
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.cycleId, id), isNull(issues.archivedAt), sql `${issues.statusCategory} not in ('done','cancelled')`));
185
+ if (unfinished.length)
186
+ await tx
187
+ .update(issues)
188
+ .set({ cycleId: target, updatedAt: new Date() })
189
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, unfinished.map((r) => r.id))));
190
+ if (target && unfinished.length)
191
+ await tx
192
+ .update(cycles)
193
+ .set({ carryOverCount: sql `${cycles.carryOverCount} + ${unfinished.length}` })
194
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, target)));
195
+ const stats = (await this.cycleStats(tx, workspaceId, [id])).get(id) ?? EMPTY_CYCLE_STATS;
196
+ const [row] = await tx
197
+ .update(cycles)
198
+ .set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
199
+ .where(and(eq(cycles.workspaceId, workspaceId), eq(cycles.id, id)))
200
+ .returning();
201
+ await this.kernel.emit(trackerEvents.cycleCompleted, {
202
+ workspaceId: workspaceId,
203
+ projectId: current.projectId,
204
+ cycleId: id,
205
+ number: current.number,
206
+ carriedOver: unfinished.length,
207
+ }, { workspaceId, actorId: principal.userId });
208
+ await this.notify.change(workspaceId, 'cycle', id, 'updated', { scope: { projectId: current.projectId } });
209
+ return toCycle(row, stats);
210
+ }
211
+ // ------------------------------------------------------------------ milestones
212
+ async countByColumn(tx, workspaceId, column, ids) {
213
+ const out = new Map();
214
+ for (const id of ids)
215
+ out.set(id, { total: 0, done: 0 });
216
+ if (!ids.length)
217
+ return out;
218
+ const rows = await tx
219
+ .select({
220
+ key: column,
221
+ total: sql `count(*)::int`,
222
+ done: sql `count(*) filter (where ${issues.statusCategory} in ('done','cancelled'))::int`,
223
+ })
224
+ .from(issues)
225
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(column, ids), isNull(issues.archivedAt)))
226
+ .groupBy(column);
227
+ for (const row of rows)
228
+ if (row.key)
229
+ out.set(row.key, { total: Number(row.total), done: Number(row.done) });
230
+ return out;
231
+ }
232
+ async listMilestones(tx, principal, workspaceId, projectId) {
233
+ await this.access.requireProject(tx, principal, workspaceId, projectId);
234
+ const rows = await tx
235
+ .select()
236
+ .from(milestones)
237
+ .where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.projectId, projectId)))
238
+ .orderBy(asc(milestones.targetDate), asc(milestones.name));
239
+ const stats = await this.countByColumn(tx, workspaceId, issues.milestoneId, rows.map((r) => r.id));
240
+ return rows.map((r) => toMilestone(r, stats.get(r.id)));
241
+ }
242
+ async createMilestone(tx, principal, workspaceId, projectId, input) {
243
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.cycle.manage');
244
+ const [row] = await tx
245
+ .insert(milestones)
246
+ .values({
247
+ id: uuidv7(),
248
+ workspaceId,
249
+ projectId,
250
+ name: input.name,
251
+ description: input.description ?? null,
252
+ targetDate: input.targetDate ?? null,
253
+ status: input.status ?? 'open',
254
+ })
255
+ .returning();
256
+ await this.notify.change(workspaceId, 'milestone', row.id, 'created', { scope: { projectId } });
257
+ return toMilestone(row);
258
+ }
259
+ async updateMilestone(tx, principal, workspaceId, id, patch) {
260
+ const [current] = await tx
261
+ .select()
262
+ .from(milestones)
263
+ .where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
264
+ .limit(1);
265
+ if (!current)
266
+ throw KernError.notFound('Milestone');
267
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
268
+ const [row] = await tx
269
+ .update(milestones)
270
+ .set({
271
+ ...(patch.name === undefined ? {} : { name: patch.name }),
272
+ ...(patch.description === undefined ? {} : { description: patch.description }),
273
+ ...(patch.targetDate === undefined ? {} : { targetDate: patch.targetDate }),
274
+ ...(patch.status === undefined
275
+ ? {}
276
+ : { status: patch.status, completedAt: patch.status === 'completed' ? new Date() : null }),
277
+ updatedAt: new Date(),
278
+ })
279
+ .where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
280
+ .returning();
281
+ const stats = await this.countByColumn(tx, workspaceId, issues.milestoneId, [id]);
282
+ await this.notify.change(workspaceId, 'milestone', id, 'updated', {
283
+ scope: { projectId: current.projectId },
284
+ });
285
+ return toMilestone(row, stats.get(id));
286
+ }
287
+ async deleteMilestone(tx, principal, workspaceId, id) {
288
+ const [current] = await tx
289
+ .select()
290
+ .from(milestones)
291
+ .where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)))
292
+ .limit(1);
293
+ if (!current)
294
+ throw KernError.notFound('Milestone');
295
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.cycle.manage');
296
+ await tx
297
+ .update(issues)
298
+ .set({ milestoneId: null })
299
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.milestoneId, id)));
300
+ await tx.delete(milestones).where(and(eq(milestones.workspaceId, workspaceId), eq(milestones.id, id)));
301
+ await this.notify.change(workspaceId, 'milestone', id, 'deleted', {
302
+ scope: { projectId: current.projectId },
303
+ });
304
+ }
305
+ // ------------------------------------------------------------------ versions
306
+ async versionStats(tx, ids) {
307
+ const out = new Map();
308
+ for (const id of ids)
309
+ out.set(id, { total: 0, done: 0 });
310
+ if (!ids.length)
311
+ return out;
312
+ const rows = await tx.execute(sql `
313
+ select v.id, count(i.id)::int as total,
314
+ count(i.id) filter (where i.status_category in ('done','cancelled'))::int as done
315
+ from unnest(${sql.param(ids)}::uuid[]) as v(id)
316
+ left join ${issues} i on v.id = any(i.version_ids) and i.archived_at is null
317
+ group by v.id`);
318
+ for (const row of rows.rows)
319
+ out.set(row.id, { total: Number(row.total), done: Number(row.done) });
320
+ return out;
321
+ }
322
+ async listVersions(tx, principal, workspaceId, projectId) {
323
+ await this.access.requireProject(tx, principal, workspaceId, projectId);
324
+ const rows = await tx
325
+ .select()
326
+ .from(versions)
327
+ .where(and(eq(versions.workspaceId, workspaceId), eq(versions.projectId, projectId)))
328
+ .orderBy(asc(versions.order), asc(versions.name));
329
+ const stats = await this.versionStats(tx, rows.map((r) => r.id));
330
+ return rows.map((r) => toVersion(r, stats.get(r.id)));
331
+ }
332
+ async createVersion(tx, principal, workspaceId, projectId, input) {
333
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.version.manage');
334
+ const [row] = await tx
335
+ .insert(versions)
336
+ .values({
337
+ id: uuidv7(),
338
+ workspaceId,
339
+ projectId,
340
+ name: input.name,
341
+ description: input.description ?? null,
342
+ startDate: input.startDate ?? null,
343
+ releaseDate: input.releaseDate ?? null,
344
+ order: input.order ?? 0,
345
+ })
346
+ .returning();
347
+ await this.notify.change(workspaceId, 'version', row.id, 'created', { scope: { projectId } });
348
+ return toVersion(row);
349
+ }
350
+ async updateVersion(tx, principal, workspaceId, id, patch) {
351
+ const current = await this.versionRow(tx, workspaceId, id);
352
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
353
+ const [row] = await tx
354
+ .update(versions)
355
+ .set({
356
+ ...(patch.name === undefined ? {} : { name: patch.name }),
357
+ ...(patch.description === undefined ? {} : { description: patch.description }),
358
+ ...(patch.startDate === undefined ? {} : { startDate: patch.startDate }),
359
+ ...(patch.releaseDate === undefined ? {} : { releaseDate: patch.releaseDate }),
360
+ ...(patch.order === undefined ? {} : { order: patch.order }),
361
+ updatedAt: new Date(),
362
+ })
363
+ .where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
364
+ .returning();
365
+ const stats = await this.versionStats(tx, [id]);
366
+ await this.notify.change(workspaceId, 'version', id, 'updated', {
367
+ scope: { projectId: current.projectId },
368
+ });
369
+ return toVersion(row, stats.get(id));
370
+ }
371
+ async versionRow(tx, workspaceId, id) {
372
+ const [row] = await tx
373
+ .select()
374
+ .from(versions)
375
+ .where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
376
+ .limit(1);
377
+ if (!row)
378
+ throw KernError.notFound('Version');
379
+ return row;
380
+ }
381
+ async deleteVersion(tx, principal, workspaceId, id) {
382
+ const current = await this.versionRow(tx, workspaceId, id);
383
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
384
+ await tx
385
+ .update(issues)
386
+ .set({
387
+ versionIds: sql `array_remove(${issues.versionIds}, ${id}::uuid)`,
388
+ affectsVersionIds: sql `array_remove(${issues.affectsVersionIds}, ${id}::uuid)`,
389
+ })
390
+ .where(sql `${id}::uuid = any(${issues.versionIds}) or ${id}::uuid = any(${issues.affectsVersionIds})`);
391
+ await tx.delete(versions).where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)));
392
+ await this.notify.change(workspaceId, 'version', id, 'deleted', {
393
+ scope: { projectId: current.projectId },
394
+ });
395
+ }
396
+ async releaseVersion(tx, principal, workspaceId, id, released) {
397
+ const current = await this.versionRow(tx, workspaceId, id);
398
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.version.manage');
399
+ const [row] = await tx
400
+ .update(versions)
401
+ .set({
402
+ status: released ? 'released' : 'unreleased',
403
+ releasedAt: released ? new Date() : null,
404
+ updatedAt: new Date(),
405
+ })
406
+ .where(and(eq(versions.workspaceId, workspaceId), eq(versions.id, id)))
407
+ .returning();
408
+ if (released)
409
+ await this.kernel.emit(trackerEvents.versionReleased, {
410
+ workspaceId: workspaceId,
411
+ projectId: current.projectId,
412
+ versionId: id,
413
+ name: current.name,
414
+ }, { workspaceId, actorId: principal.userId });
415
+ const stats = await this.versionStats(tx, [id]);
416
+ await this.notify.change(workspaceId, 'version', id, 'updated', {
417
+ scope: { projectId: current.projectId },
418
+ });
419
+ return toVersion(row, stats.get(id));
420
+ }
421
+ // ------------------------------------------------------------------ components
422
+ async listComponents(tx, principal, workspaceId, projectId) {
423
+ await this.access.requireProject(tx, principal, workspaceId, projectId);
424
+ const rows = await tx
425
+ .select()
426
+ .from(components)
427
+ .where(and(eq(components.workspaceId, workspaceId), eq(components.projectId, projectId)))
428
+ .orderBy(asc(components.name));
429
+ const counts = await this.arrayCounts(tx, workspaceId, 'component_ids', rows.map((r) => r.id));
430
+ return rows.map((r) => toComponent(r, counts.get(r.id) ?? 0));
431
+ }
432
+ async arrayCounts(tx, workspaceId, column, ids) {
433
+ const out = new Map();
434
+ if (!ids.length)
435
+ return out;
436
+ const rows = await tx.execute(sql `
437
+ select v.id, count(i.id)::int as n
438
+ from unnest(${sql.param(ids)}::uuid[]) as v(id)
439
+ left join ${issues} i on v.id = any(i.${sql.raw(column)}) and i.archived_at is null
440
+ group by v.id`);
441
+ for (const row of rows.rows)
442
+ out.set(row.id, Number(row.n));
443
+ return out;
444
+ }
445
+ async createComponent(tx, principal, workspaceId, projectId, input) {
446
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
447
+ const [row] = await tx
448
+ .insert(components)
449
+ .values({
450
+ id: uuidv7(),
451
+ workspaceId,
452
+ projectId,
453
+ name: input.name,
454
+ description: input.description ?? null,
455
+ leadId: input.leadId ?? null,
456
+ defaultAssignee: input.defaultAssignee ?? 'project',
457
+ })
458
+ .returning();
459
+ await this.notify.change(workspaceId, 'component', row.id, 'created', { scope: { projectId } });
460
+ return toComponent(row);
461
+ }
462
+ async updateComponent(tx, principal, workspaceId, id, patch) {
463
+ const [current] = await tx
464
+ .select()
465
+ .from(components)
466
+ .where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
467
+ .limit(1);
468
+ if (!current)
469
+ throw KernError.notFound('Component');
470
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
471
+ const [row] = await tx
472
+ .update(components)
473
+ .set({
474
+ ...(patch.name === undefined ? {} : { name: patch.name }),
475
+ ...(patch.description === undefined ? {} : { description: patch.description }),
476
+ ...(patch.leadId === undefined ? {} : { leadId: patch.leadId }),
477
+ ...(patch.defaultAssignee === undefined ? {} : { defaultAssignee: patch.defaultAssignee }),
478
+ updatedAt: new Date(),
479
+ })
480
+ .where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
481
+ .returning();
482
+ const counts = await this.arrayCounts(tx, workspaceId, 'component_ids', [id]);
483
+ await this.notify.change(workspaceId, 'component', id, 'updated', {
484
+ scope: { projectId: current.projectId },
485
+ });
486
+ return toComponent(row, counts.get(id) ?? 0);
487
+ }
488
+ async deleteComponent(tx, principal, workspaceId, id) {
489
+ const [current] = await tx
490
+ .select()
491
+ .from(components)
492
+ .where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)))
493
+ .limit(1);
494
+ if (!current)
495
+ throw KernError.notFound('Component');
496
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
497
+ await tx
498
+ .update(issues)
499
+ .set({ componentIds: sql `array_remove(${issues.componentIds}, ${id}::uuid)` })
500
+ .where(sql `${id}::uuid = any(${issues.componentIds})`);
501
+ await tx.delete(components).where(and(eq(components.workspaceId, workspaceId), eq(components.id, id)));
502
+ await this.notify.change(workspaceId, 'component', id, 'deleted', {
503
+ scope: { projectId: current.projectId },
504
+ });
505
+ }
506
+ // ------------------------------------------------------------------ labels
507
+ async listLabels(tx, principal, workspaceId, projectId, includeArchived = false) {
508
+ if (projectId)
509
+ await this.access.requireProject(tx, principal, workspaceId, projectId);
510
+ const filters = [eq(labels.workspaceId, workspaceId)];
511
+ if (!includeArchived)
512
+ filters.push(isNull(labels.archivedAt));
513
+ if (projectId)
514
+ filters.push(sql `(${labels.projectId} is null or ${labels.projectId} = ${projectId})`);
515
+ const rows = await tx
516
+ .select()
517
+ .from(labels)
518
+ .where(and(...filters))
519
+ .orderBy(asc(labels.groupName), asc(labels.name));
520
+ const counts = await this.arrayCounts(tx, workspaceId, 'label_ids', rows.map((r) => r.id));
521
+ return rows.map((r) => toLabel(r, counts.get(r.id) ?? 0));
522
+ }
523
+ /**
524
+ * Labels are project configuration. A label attached to a project needs `tracker.project.manage`
525
+ * on that project; a workspace-wide label (`projectId === null`) has no project whose scheme could
526
+ * grant it, so it takes the same permission at workspace scope.
527
+ */
528
+ async requireLabelManage(tx, principal, workspaceId, projectId) {
529
+ if (projectId)
530
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
531
+ else
532
+ await this.access.requireWorkspace(principal, 'tracker.project.manage', workspaceId);
533
+ }
534
+ async createLabel(tx, principal, workspaceId, input) {
535
+ await this.requireLabelManage(tx, principal, workspaceId, input.projectId ?? null);
536
+ const [row] = await tx
537
+ .insert(labels)
538
+ .values({
539
+ id: uuidv7(),
540
+ workspaceId,
541
+ projectId: input.projectId ?? null,
542
+ name: input.name,
543
+ color: input.color ?? null,
544
+ description: input.description ?? null,
545
+ groupName: input.groupName ?? null,
546
+ })
547
+ .returning();
548
+ await this.notify.change(workspaceId, 'label', row.id, 'created');
549
+ return toLabel(row);
550
+ }
551
+ async updateLabel(tx, principal, workspaceId, id, patch) {
552
+ const current = await this.labelRow(tx, workspaceId, id);
553
+ await this.requireLabelManage(tx, principal, workspaceId, current.projectId);
554
+ // moving a label into (or out of) a project needs the permission on the destination too
555
+ if (patch.projectId !== undefined && (patch.projectId ?? null) !== current.projectId)
556
+ await this.requireLabelManage(tx, principal, workspaceId, patch.projectId ?? null);
557
+ const [row] = await tx
558
+ .update(labels)
559
+ .set({
560
+ ...(patch.name === undefined ? {} : { name: patch.name }),
561
+ ...(patch.color === undefined ? {} : { color: patch.color }),
562
+ ...(patch.description === undefined ? {} : { description: patch.description }),
563
+ ...(patch.groupName === undefined ? {} : { groupName: patch.groupName }),
564
+ ...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
565
+ })
566
+ .where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)))
567
+ .returning();
568
+ if (!row)
569
+ throw KernError.notFound('Label');
570
+ const counts = await this.arrayCounts(tx, workspaceId, 'label_ids', [id]);
571
+ await this.notify.change(workspaceId, 'label', id, 'updated');
572
+ return toLabel(row, counts.get(id) ?? 0);
573
+ }
574
+ async deleteLabel(tx, principal, workspaceId, id) {
575
+ const current = await this.labelRow(tx, workspaceId, id);
576
+ await this.requireLabelManage(tx, principal, workspaceId, current.projectId);
577
+ await tx
578
+ .update(issues)
579
+ .set({ labelIds: sql `array_remove(${issues.labelIds}, ${id}::uuid)` })
580
+ .where(and(eq(issues.workspaceId, workspaceId), sql `${id}::uuid = any(${issues.labelIds})`));
581
+ await tx.delete(labels).where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)));
582
+ await this.notify.change(workspaceId, 'label', id, 'deleted');
583
+ }
584
+ async labelRow(tx, workspaceId, id) {
585
+ const [row] = await tx
586
+ .select()
587
+ .from(labels)
588
+ .where(and(eq(labels.workspaceId, workspaceId), eq(labels.id, id)))
589
+ .limit(1);
590
+ if (!row)
591
+ throw KernError.notFound('Label');
592
+ return row;
593
+ }
594
+ /** Find or create a label by name, used by CSV import and email ingest. */
595
+ async ensureLabel(tx, workspaceId, projectId, name) {
596
+ const [existing] = await tx
597
+ .select({ id: labels.id })
598
+ .from(labels)
599
+ .where(and(eq(labels.workspaceId, workspaceId), eq(labels.name, name), projectId ? eq(labels.projectId, projectId) : isNull(labels.projectId)))
600
+ .limit(1);
601
+ if (existing)
602
+ return existing.id;
603
+ const [row] = await tx
604
+ .insert(labels)
605
+ .values({ id: uuidv7(), workspaceId, projectId, name })
606
+ .returning({ id: labels.id });
607
+ return row.id;
608
+ }
609
+ /** Statuses considered resolved when rolling a cycle over. */
610
+ resolvedCategories() {
611
+ return RESOLVED;
612
+ }
613
+ }
614
+ //# sourceMappingURL=planning.js.map