@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,1381 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { initialStatus, RESOLVED_CATEGORIES } from '@kernhq/workflow';
3
+ import { and, asc, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm';
4
+ import { trackerEvents } from '../../contract/events.js';
5
+ import { MODULE_ID, RELATION_INVERSE, } from '../../contract/models.js';
6
+ import { initialRank, rankBetween } from '../rank.js';
7
+ import { docToText, extractMentions, preview } from '../rich.js';
8
+ import { attachments, comments, components, cycles, issueApprovals, issueCounters, issueHistory, issueStatusHistory, issues, issueTemplates, labels, links, milestones, projects, recurringIssues, relations, timers, versions, workItemTypes, worklogs, } from '../schema.js';
9
+ import { added, EMPTY_RELATION_SUMMARY, issueUrl, parseSettings, removed, toAttachment, toHistoryEntry, toIssue, toIssueSummary, toIssueTemplate, toLink, toRecurringIssue, toStatusHistoryEntry, uniq, } from './db.js';
10
+ const objectRef = (id) => ({ module: MODULE_ID, type: 'issue', id });
11
+ /** Issues and everything attached to them: relations, attachments, links, watchers, history. */
12
+ export class IssueService {
13
+ kernel;
14
+ access;
15
+ config;
16
+ notify;
17
+ constructor(kernel, access, config, notify) {
18
+ this.kernel = kernel;
19
+ this.access = access;
20
+ this.config = config;
21
+ this.notify = notify;
22
+ }
23
+ // ------------------------------------------------------------------ reads
24
+ async row(tx, workspaceId, issueId) {
25
+ const [row] = await tx
26
+ .select()
27
+ .from(issues)
28
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
29
+ .limit(1);
30
+ if (!row)
31
+ throw KernError.notFound('Issue');
32
+ return row;
33
+ }
34
+ /** Relation and sub-item counters for a page of issues, in two aggregate queries. */
35
+ async relationSummaries(tx, ids) {
36
+ const out = new Map();
37
+ if (!ids.length)
38
+ return out;
39
+ for (const id of ids)
40
+ out.set(id, { ...EMPTY_RELATION_SUMMARY });
41
+ const rel = await tx.execute(sql `
42
+ select r.${sql.raw('from_issue_id')} as issue_id, r.type, count(*)::int as n,
43
+ count(*) filter (where t.status_category not in ('done', 'cancelled'))::int as open
44
+ from ${relations} r join ${issues} t on t.id = r.${sql.raw('to_issue_id')}
45
+ where r.${sql.raw('from_issue_id')} = any(${sql.param(ids)}::uuid[])
46
+ group by 1, 2`);
47
+ for (const r of rel.rows) {
48
+ const entry = out.get(r.issue_id);
49
+ if (!entry)
50
+ continue;
51
+ const n = Number(r.n);
52
+ if (r.type === 'blocks')
53
+ entry.blocks += n;
54
+ else if (r.type === 'blocked_by') {
55
+ entry.blockedBy += n;
56
+ entry.openBlockers += Number(r.open);
57
+ }
58
+ else if (r.type === 'relates')
59
+ entry.relates += n;
60
+ else if (r.type === 'duplicates' || r.type === 'duplicated_by')
61
+ entry.duplicates += n;
62
+ }
63
+ const subs = await tx.execute(sql `
64
+ select ${issues.parentId} as parent_id, count(*)::int as total,
65
+ count(*) filter (where ${issues.statusCategory} in ('done', 'cancelled'))::int as done
66
+ from ${issues}
67
+ where ${issues.parentId} = any(${sql.param(ids)}::uuid[]) and ${issues.archivedAt} is null
68
+ group by 1`);
69
+ for (const r of subs.rows) {
70
+ const entry = out.get(r.parent_id);
71
+ if (!entry)
72
+ continue;
73
+ entry.subItems = Number(r.total);
74
+ entry.subItemsDone = Number(r.done);
75
+ }
76
+ return out;
77
+ }
78
+ async hydrate(tx, rows) {
79
+ const summaries = await this.relationSummaries(tx, rows.map((r) => r.id));
80
+ return rows.map((r) => toIssue(r, summaries.get(r.id)));
81
+ }
82
+ async get(tx, principal, workspaceId, issueId) {
83
+ const row = await this.row(tx, workspaceId, issueId);
84
+ await this.access.requireProject(tx, principal, workspaceId, row.projectId, 'tracker.issue.view');
85
+ return (await this.hydrate(tx, [row]))[0];
86
+ }
87
+ async getByKey(tx, principal, workspaceId, key) {
88
+ const [row] = await tx
89
+ .select()
90
+ .from(issues)
91
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.key, key.toUpperCase())))
92
+ .limit(1);
93
+ if (!row)
94
+ throw KernError.notFound('Issue');
95
+ await this.access.requireProject(tx, principal, workspaceId, row.projectId, 'tracker.issue.view');
96
+ return (await this.hydrate(tx, [row]))[0];
97
+ }
98
+ async getMany(tx, principal, workspaceId, ids) {
99
+ if (!ids.length)
100
+ return [];
101
+ const rows = await tx
102
+ .select()
103
+ .from(issues)
104
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, ids)));
105
+ const allowed = new Set(await this.access.visibleProjectIds(tx, principal, workspaceId, { includeArchived: true }));
106
+ const visible = rows.filter((r) => allowed.has(r.projectId));
107
+ const hydrated = await this.hydrate(tx, visible);
108
+ const byId = new Map(hydrated.map((i) => [i.id, i]));
109
+ return ids.map((id) => byId.get(id)).filter((i) => !!i);
110
+ }
111
+ // ------------------------------------------------------------------ create
112
+ async create(tx, principal, workspaceId, input, opts = {}) {
113
+ const project = opts.system
114
+ ? await this.access.loadProject(tx, workspaceId, input.projectId)
115
+ : await this.access.requireProject(tx, principal, workspaceId, input.projectId, 'tracker.issue.create');
116
+ if (project.archivedAt)
117
+ throw KernError.conflict('Project is archived', 'tracker.project.archived');
118
+ const merged = input.templateId
119
+ ? await this.applyTemplate(tx, workspaceId, input)
120
+ : { input, subItems: [] };
121
+ const data = merged.input;
122
+ const settings = parseSettings(project.settings);
123
+ const type = data.typeId
124
+ ? await this.config.getType(tx, workspaceId, data.typeId)
125
+ : await this.config.defaultType(tx, workspaceId, project.id);
126
+ const { definition } = await this.config.workflowFor(tx, project, type.id);
127
+ const triage = opts.triage ?? data.triage ?? false;
128
+ const status = this.pickInitialStatus(definition, data.statusId, triage);
129
+ const now = new Date();
130
+ const number = await this.allocateNumber(tx, workspaceId, project.id);
131
+ const key = `${project.key}-${number}`;
132
+ const rank = await this.rankFor(tx, workspaceId, project.id, data.rankAfterId, data.rankBeforeId);
133
+ const parentId = data.parentId ?? null;
134
+ if (parentId)
135
+ await this.assertParentAllowed(tx, workspaceId, project, type.level, parentId);
136
+ const assigneeIds = await this.defaultAssignees(tx, project, data, type.id);
137
+ const labelIds = await this.enforceLabelGroups(tx, workspaceId, data.labelIds ?? []);
138
+ await this.assertPlanningRefs(tx, project.id, data);
139
+ const description = (data.description ?? null);
140
+ const descriptionText = docToText(description);
141
+ const custom = await this.withFieldDefaults(tx, workspaceId, project.id, data.custom ?? {});
142
+ const watchers = uniq([
143
+ ...(data.watcherIds ?? []),
144
+ ...assigneeIds,
145
+ ...(principal.userId ? [principal.userId] : []),
146
+ ...(data.reporterId ? [data.reporterId] : []),
147
+ ]);
148
+ const source = opts.source ?? 'app';
149
+ const sla = this.initialSla(settings, data.priority ?? 'none', now, triage);
150
+ const id = uuidv7();
151
+ const [row] = await tx
152
+ .insert(issues)
153
+ .values({
154
+ id,
155
+ workspaceId,
156
+ projectId: project.id,
157
+ key,
158
+ number,
159
+ typeId: type.id,
160
+ title: data.title,
161
+ description,
162
+ descriptionText,
163
+ statusId: status.id,
164
+ statusCategory: status.category,
165
+ priority: data.priority ?? 'none',
166
+ assigneeIds,
167
+ reporterId: data.reporterId ?? principal.userId ?? null,
168
+ creatorId: principal.userId ?? null,
169
+ labelIds,
170
+ componentIds: data.componentIds ?? [],
171
+ versionIds: data.versionIds ?? [],
172
+ affectsVersionIds: data.affectsVersionIds ?? [],
173
+ cycleId: data.cycleId ?? null,
174
+ milestoneId: data.milestoneId ?? null,
175
+ parentId,
176
+ rank,
177
+ estimate: data.estimate ?? null,
178
+ estimateUnit: settings.estimation,
179
+ startDate: data.startDate ?? null,
180
+ dueDate: data.dueDate ?? null,
181
+ custom,
182
+ watcherIds: watchers,
183
+ originalEstimateSec: data.originalEstimateSec ?? null,
184
+ remainingSec: data.originalEstimateSec ?? null,
185
+ sla,
186
+ triage,
187
+ source,
188
+ externalRef: opts.externalRef ?? null,
189
+ })
190
+ .returning();
191
+ await tx.insert(issueStatusHistory).values({
192
+ id: uuidv7(),
193
+ workspaceId,
194
+ issueId: id,
195
+ projectId: project.id,
196
+ fromStatusId: null,
197
+ toStatusId: status.id,
198
+ fromCategory: null,
199
+ toCategory: status.category,
200
+ actorId: principal.userId ?? null,
201
+ transitionId: null,
202
+ durationSec: null,
203
+ });
204
+ await this.notify.history(tx, {
205
+ workspaceId,
206
+ issueId: id,
207
+ actorId: principal.userId ?? null,
208
+ action: 'created',
209
+ data: { key, title: data.title },
210
+ });
211
+ if (data.attachmentIds?.length)
212
+ await this.attachFiles(tx, workspaceId, id, data.attachmentIds, principal.userId ?? null);
213
+ for (const sub of merged.subItems) {
214
+ const subType = sub.typeId ?? (await this.subItemType(tx, workspaceId, project.id, type.level));
215
+ await this.create(tx, principal, workspaceId, { projectId: project.id, title: sub.title, typeId: subType, parentId: id }, { ...opts, system: true });
216
+ }
217
+ const issue = (await this.hydrate(tx, [row]))[0];
218
+ await this.afterCreate(issue, principal, { mentions: extractMentions(description) });
219
+ if (settings.autoCreateIssueChannel)
220
+ void this.ensureChatChannel(workspaceId, issue, watchers);
221
+ return issue;
222
+ }
223
+ pickInitialStatus(definition, requested, triage) {
224
+ if (requested) {
225
+ const found = definition.statuses.find((s) => s.id === requested);
226
+ if (!found)
227
+ throw KernError.badRequest(`Unknown status "${requested}" for this workflow`);
228
+ return found;
229
+ }
230
+ if (triage) {
231
+ const triageStatus = definition.statuses.find((s) => s.category === 'triage');
232
+ if (triageStatus)
233
+ return triageStatus;
234
+ }
235
+ return initialStatus(definition);
236
+ }
237
+ async allocateNumber(tx, workspaceId, projectId) {
238
+ await tx
239
+ .insert(issueCounters)
240
+ .values({ projectId, workspaceId })
241
+ .onConflictDoNothing({ target: issueCounters.projectId });
242
+ const [row] = await tx
243
+ .update(issueCounters)
244
+ .set({ lastIssueNumber: sql `${issueCounters.lastIssueNumber} + 1` })
245
+ .where(eq(issueCounters.projectId, projectId))
246
+ .returning({ n: issueCounters.lastIssueNumber });
247
+ if (!row)
248
+ throw KernError.conflict('Could not allocate an issue number');
249
+ return row.n;
250
+ }
251
+ async rankFor(tx, workspaceId, projectId, afterId, beforeId) {
252
+ if (afterId || beforeId)
253
+ return this.rankBetweenIssues(tx, workspaceId, afterId ?? null, beforeId ?? null);
254
+ const [last] = await tx
255
+ .select({ rank: issues.rank })
256
+ .from(issues)
257
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.projectId, projectId)))
258
+ .orderBy(desc(issues.rank))
259
+ .limit(1);
260
+ return last ? rankBetween(last.rank, null) : initialRank();
261
+ }
262
+ async rankBetweenIssues(tx, workspaceId, afterId, beforeId) {
263
+ const ids = [afterId, beforeId].filter((v) => !!v);
264
+ const rows = ids.length
265
+ ? await tx
266
+ .select({ id: issues.id, rank: issues.rank })
267
+ .from(issues)
268
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, ids)))
269
+ : [];
270
+ const after = afterId ? (rows.find((r) => r.id === afterId)?.rank ?? null) : null;
271
+ const before = beforeId ? (rows.find((r) => r.id === beforeId)?.rank ?? null) : null;
272
+ return rankBetween(after, before);
273
+ }
274
+ async defaultAssignees(tx, project, input, _typeId) {
275
+ if (input.assigneeIds?.length)
276
+ return uniq(input.assigneeIds);
277
+ if (input.componentIds?.length) {
278
+ const rows = await tx.select().from(components).where(inArray(components.id, input.componentIds));
279
+ for (const component of rows) {
280
+ if (component.defaultAssignee === 'lead' && component.leadId)
281
+ return [component.leadId];
282
+ if (component.defaultAssignee === 'none')
283
+ return [];
284
+ }
285
+ }
286
+ if (project.defaultAssignee === 'lead' && project.leadId)
287
+ return [project.leadId];
288
+ return [];
289
+ }
290
+ /** Labels that share a group are mutually exclusive: the last one wins. */
291
+ async enforceLabelGroups(tx, workspaceId, labelIds) {
292
+ if (labelIds.length < 2)
293
+ return uniq(labelIds);
294
+ const rows = await tx
295
+ .select()
296
+ .from(labels)
297
+ .where(and(eq(labels.workspaceId, workspaceId), inArray(labels.id, labelIds)));
298
+ const byGroup = new Map();
299
+ const out = [];
300
+ for (const id of uniq(labelIds)) {
301
+ const label = rows.find((r) => r.id === id);
302
+ if (!label?.groupName) {
303
+ out.push(id);
304
+ continue;
305
+ }
306
+ byGroup.set(label.groupName, id);
307
+ }
308
+ return [...out, ...byGroup.values()];
309
+ }
310
+ async assertPlanningRefs(tx, projectId, input) {
311
+ if (input.cycleId) {
312
+ const [row] = await tx.select({ p: cycles.projectId }).from(cycles).where(eq(cycles.id, input.cycleId));
313
+ if (!row || row.p !== projectId)
314
+ throw KernError.badRequest('Cycle belongs to another project');
315
+ }
316
+ if (input.milestoneId) {
317
+ const [row] = await tx
318
+ .select({ p: milestones.projectId })
319
+ .from(milestones)
320
+ .where(eq(milestones.id, input.milestoneId));
321
+ if (!row || row.p !== projectId)
322
+ throw KernError.badRequest('Milestone belongs to another project');
323
+ }
324
+ for (const id of [...(input.versionIds ?? []), ...(input.affectsVersionIds ?? [])]) {
325
+ const [row] = await tx.select({ p: versions.projectId }).from(versions).where(eq(versions.id, id));
326
+ if (!row || row.p !== projectId)
327
+ throw KernError.badRequest('Version belongs to another project');
328
+ }
329
+ }
330
+ async assertParentAllowed(tx, workspaceId, project, childLevel, parentId) {
331
+ const parent = await this.row(tx, workspaceId, parentId);
332
+ if (parent.projectId !== project.id && childLevel >= 0)
333
+ throw KernError.badRequest('Parent must be in the same project');
334
+ const parentType = await this.config.getType(tx, workspaceId, parent.typeId);
335
+ const rules = await this.config.hierarchyRules(tx, workspaceId);
336
+ if (parentType.level === childLevel && !rules.allowSameLevel)
337
+ throw KernError.badRequest('Same-level parenting is disabled in this workspace');
338
+ if (parentType.level < childLevel)
339
+ throw KernError.badRequest('A parent must sit above its child');
340
+ if (parentType.level - childLevel > 1 && !rules.allowSkipLevels)
341
+ throw KernError.badRequest('Skipping hierarchy levels is disabled in this workspace');
342
+ if (childLevel < 0) {
343
+ let depth = 1;
344
+ let cursor = parent.parentId;
345
+ while (cursor && depth <= rules.maxSubItemDepth + 1) {
346
+ const next = await this.row(tx, workspaceId, cursor);
347
+ const nextType = await this.config.getType(tx, workspaceId, next.typeId);
348
+ if (nextType.level >= 0)
349
+ break;
350
+ depth++;
351
+ cursor = next.parentId;
352
+ }
353
+ if (depth > rules.maxSubItemDepth)
354
+ throw KernError.badRequest(`Sub-items may only nest ${rules.maxSubItemDepth} level(s) deep`);
355
+ }
356
+ }
357
+ async subItemType(tx, workspaceId, projectId, parentLevel) {
358
+ const types = await this.config.listTypes(tx, workspaceId, { projectId });
359
+ const below = types.filter((t) => t.level < parentLevel).sort((a, b) => b.level - a.level);
360
+ const chosen = below[0] ?? types.find((t) => t.level === parentLevel);
361
+ if (!chosen)
362
+ throw KernError.badRequest('No work item type available for sub-items');
363
+ return chosen.id;
364
+ }
365
+ async withFieldDefaults(tx, workspaceId, projectId, custom) {
366
+ const defs = await this.config.listFieldRows(tx, workspaceId, { projectId });
367
+ const out = { ...custom };
368
+ for (const def of defs) {
369
+ if (out[def.key] === undefined && def.defaultValue != null)
370
+ out[def.key] = def.defaultValue;
371
+ if (def.required && (out[def.key] === undefined || out[def.key] === null))
372
+ throw KernError.badRequest(`Custom field "${def.name}" is required`, { field: `cf.${def.key}` });
373
+ }
374
+ return out;
375
+ }
376
+ initialSla(settings, priority, now, triage) {
377
+ if (!settings.sla.enabled)
378
+ return null;
379
+ const goal = settings.sla.goals[priority];
380
+ if (!goal)
381
+ return null;
382
+ const hours = (h) => (h ? new Date(now.getTime() + h * 3_600_000).toISOString() : null);
383
+ return {
384
+ firstResponseDueAt: hours(goal.firstResponseHours),
385
+ firstRespondedAt: null,
386
+ resolveDueAt: hours(goal.resolveHours),
387
+ pausedAt: triage && settings.sla.pauseInCategories.includes('triage') ? now.toISOString() : null,
388
+ pausedSec: 0,
389
+ breached: false,
390
+ };
391
+ }
392
+ async applyTemplate(tx, workspaceId, input) {
393
+ const [row] = await tx
394
+ .select()
395
+ .from(issueTemplates)
396
+ .where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, input.templateId)))
397
+ .limit(1);
398
+ if (!row)
399
+ throw KernError.notFound('Issue template');
400
+ const defaults = row.defaults ?? {};
401
+ const { templateId: _ignored, ...rest } = input;
402
+ return {
403
+ input: {
404
+ ...defaults,
405
+ ...rest,
406
+ typeId: input.typeId ?? row.typeId ?? defaults.typeId,
407
+ projectId: input.projectId,
408
+ title: input.title,
409
+ },
410
+ subItems: row.subItems ?? [],
411
+ };
412
+ }
413
+ async afterCreate(issue, principal, extra) {
414
+ await this.kernel.emit(trackerEvents.issueCreated, {
415
+ workspaceId: issue.workspaceId,
416
+ projectId: issue.projectId,
417
+ issueId: issue.id,
418
+ key: issue.key,
419
+ typeId: issue.typeId,
420
+ title: issue.title,
421
+ statusId: issue.statusId,
422
+ priority: issue.priority,
423
+ assigneeIds: issue.assigneeIds,
424
+ triage: issue.triage,
425
+ source: issue.source,
426
+ }, { workspaceId: issue.workspaceId, actorId: principal.userId });
427
+ await this.notify.change(issue.workspaceId, 'issue', issue.id, 'created', {
428
+ scope: { projectId: issue.projectId },
429
+ });
430
+ await this.notify.notify({
431
+ workspaceId: issue.workspaceId,
432
+ userIds: issue.assigneeIds,
433
+ type: 'tracker.issue.assigned',
434
+ title: `${issue.key} assigned to you`,
435
+ body: issue.title,
436
+ object: objectRef(issue.id),
437
+ url: issueUrl(issue.key),
438
+ groupKey: issue.id,
439
+ actorId: principal.userId,
440
+ exclude: [principal.userId],
441
+ });
442
+ await this.notify.notify({
443
+ workspaceId: issue.workspaceId,
444
+ userIds: extra.mentions,
445
+ type: 'tracker.issue.mentioned',
446
+ title: `You were mentioned in ${issue.key}`,
447
+ body: preview(issue.descriptionText),
448
+ object: objectRef(issue.id),
449
+ url: issueUrl(issue.key),
450
+ groupKey: issue.id,
451
+ actorId: principal.userId,
452
+ exclude: [principal.userId],
453
+ });
454
+ await this.reindex(issue);
455
+ }
456
+ async ensureChatChannel(workspaceId, issue, memberIds) {
457
+ try {
458
+ const channel = await this.kernel.call('chat.channels.ensureObjectChannel', {
459
+ workspaceId,
460
+ objectRef: objectRef(issue.id),
461
+ name: `${issue.key} ${issue.title}`.slice(0, 120),
462
+ memberIds,
463
+ });
464
+ if (channel?.id)
465
+ await this.kernel.database.withWorkspace(workspaceId, (tx) => tx.update(issues).set({ chatChannelId: channel.id }).where(eq(issues.id, issue.id)));
466
+ }
467
+ catch (err) {
468
+ this.kernel.log.debug({ err: String(err) }, 'tracker: issue chat channel unavailable');
469
+ }
470
+ }
471
+ /** Push one issue into the workspace search index. */
472
+ async reindex(issue) {
473
+ await this.notify.index([
474
+ {
475
+ workspaceId: issue.workspaceId,
476
+ object: objectRef(issue.id),
477
+ title: `${issue.key} ${issue.title}`,
478
+ body: issue.descriptionText || null,
479
+ url: issueUrl(issue.key),
480
+ icon: 'square-check-big',
481
+ acl: null,
482
+ updatedAt: issue.updatedAt,
483
+ attributes: {
484
+ projectId: issue.projectId,
485
+ statusId: issue.statusId,
486
+ statusCategory: issue.statusCategory,
487
+ priority: issue.priority,
488
+ assigneeIds: issue.assigneeIds,
489
+ },
490
+ },
491
+ ]);
492
+ }
493
+ // ------------------------------------------------------------------ update
494
+ async update(tx, principal, workspaceId, issueId, patch, opts = {}) {
495
+ const current = await this.row(tx, workspaceId, issueId);
496
+ const project = opts.system
497
+ ? await this.access.loadProject(tx, workspaceId, current.projectId)
498
+ : await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
499
+ if (!opts.system)
500
+ await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
501
+ reporterId: current.reporterId,
502
+ creatorId: current.creatorId,
503
+ assigneeIds: current.assigneeIds ?? [],
504
+ });
505
+ const next = {};
506
+ const changes = [];
507
+ const track = (field, from, to) => {
508
+ if (JSON.stringify(from) === JSON.stringify(to))
509
+ return;
510
+ next[field] = to;
511
+ changes.push({ field, from, to });
512
+ };
513
+ if (patch.title !== undefined)
514
+ track('title', current.title, patch.title);
515
+ if (patch.description !== undefined) {
516
+ const text = docToText(patch.description);
517
+ track('description', current.descriptionText, text);
518
+ next.description = patch.description;
519
+ next.descriptionText = text;
520
+ }
521
+ if (patch.priority !== undefined)
522
+ track('priority', current.priority, patch.priority);
523
+ if (patch.reporterId !== undefined)
524
+ track('reporterId', current.reporterId, patch.reporterId);
525
+ if (patch.estimate !== undefined)
526
+ track('estimate', current.estimate, patch.estimate);
527
+ if (patch.startDate !== undefined)
528
+ track('startDate', current.startDate, patch.startDate);
529
+ if (patch.dueDate !== undefined)
530
+ track('dueDate', current.dueDate, patch.dueDate);
531
+ if (patch.resolution !== undefined)
532
+ track('resolution', current.resolution, patch.resolution);
533
+ if (patch.triage !== undefined)
534
+ track('triage', current.triage, patch.triage);
535
+ if (patch.originalEstimateSec !== undefined)
536
+ track('originalEstimateSec', current.originalEstimateSec, patch.originalEstimateSec);
537
+ if (patch.remainingSec !== undefined)
538
+ track('remainingSec', current.remainingSec, patch.remainingSec);
539
+ if (patch.componentIds !== undefined)
540
+ track('componentIds', current.componentIds ?? [], uniq(patch.componentIds));
541
+ if (patch.versionIds !== undefined)
542
+ track('versionIds', current.versionIds ?? [], uniq(patch.versionIds));
543
+ if (patch.affectsVersionIds !== undefined)
544
+ track('affectsVersionIds', current.affectsVersionIds ?? [], uniq(patch.affectsVersionIds));
545
+ if (patch.cycleId !== undefined)
546
+ track('cycleId', current.cycleId, patch.cycleId);
547
+ if (patch.milestoneId !== undefined)
548
+ track('milestoneId', current.milestoneId, patch.milestoneId);
549
+ await this.assertPlanningRefs(tx, current.projectId, {
550
+ cycleId: patch.cycleId ?? undefined,
551
+ milestoneId: patch.milestoneId ?? undefined,
552
+ versionIds: patch.versionIds,
553
+ affectsVersionIds: patch.affectsVersionIds,
554
+ });
555
+ // assignees: full replacement or add/remove deltas
556
+ const currentAssignees = current.assigneeIds ?? [];
557
+ let assignees = patch.assigneeIds ? uniq(patch.assigneeIds) : [...currentAssignees];
558
+ if (patch.assigneeAdd?.length)
559
+ assignees = uniq([...assignees, ...patch.assigneeAdd]);
560
+ if (patch.assigneeRemove?.length) {
561
+ const drop = new Set(patch.assigneeRemove);
562
+ assignees = assignees.filter((a) => !drop.has(a));
563
+ }
564
+ const assigneesChanged = JSON.stringify(assignees) !== JSON.stringify(currentAssignees);
565
+ if (assigneesChanged) {
566
+ if (!opts.system)
567
+ await this.access.require(principal, 'tracker.issue.assign', workspaceId, current.projectId);
568
+ track('assigneeIds', currentAssignees, assignees);
569
+ next.watcherIds = uniq([...(current.watcherIds ?? []), ...assignees]);
570
+ }
571
+ const currentLabels = current.labelIds ?? [];
572
+ let labelIds = patch.labelIds ? uniq(patch.labelIds) : [...currentLabels];
573
+ if (patch.labelAdd?.length)
574
+ labelIds = uniq([...labelIds, ...patch.labelAdd]);
575
+ if (patch.labelRemove?.length) {
576
+ const drop = new Set(patch.labelRemove);
577
+ labelIds = labelIds.filter((l) => !drop.has(l));
578
+ }
579
+ labelIds = await this.enforceLabelGroups(tx, workspaceId, labelIds);
580
+ if (JSON.stringify(labelIds) !== JSON.stringify(currentLabels))
581
+ track('labelIds', currentLabels, labelIds);
582
+ if (patch.custom !== undefined) {
583
+ const merged = { ...(current.custom ?? {}) };
584
+ for (const [key, value] of Object.entries(patch.custom)) {
585
+ if (value === null)
586
+ delete merged[key];
587
+ else
588
+ merged[key] = value;
589
+ }
590
+ track('custom', current.custom, merged);
591
+ }
592
+ if (patch.parentId !== undefined && patch.parentId !== current.parentId) {
593
+ if (patch.parentId) {
594
+ if (patch.parentId === issueId)
595
+ throw KernError.badRequest('An issue cannot be its own parent');
596
+ const type = await this.config.getType(tx, workspaceId, patch.typeId ?? current.typeId);
597
+ await this.assertParentAllowed(tx, workspaceId, project, type.level, patch.parentId);
598
+ }
599
+ track('parentId', current.parentId, patch.parentId);
600
+ }
601
+ // a new type may follow a different workflow; keep the issue in a status that workflow knows
602
+ let statusChange = null;
603
+ if (patch.typeId !== undefined && patch.typeId !== current.typeId) {
604
+ await this.config.getType(tx, workspaceId, patch.typeId);
605
+ track('typeId', current.typeId, patch.typeId);
606
+ const { definition } = await this.config.workflowFor(tx, project, patch.typeId);
607
+ if (!definition.statuses.some((s) => s.id === current.statusId)) {
608
+ const target = initialStatus(definition);
609
+ next.statusId = target.id;
610
+ next.statusCategory = target.category;
611
+ statusChange = {
612
+ fromStatusId: current.statusId,
613
+ toStatusId: target.id,
614
+ toCategory: target.category,
615
+ };
616
+ }
617
+ }
618
+ if (!changes.length && !statusChange)
619
+ return (await this.hydrate(tx, [current]))[0];
620
+ const [row] = await tx
621
+ .update(issues)
622
+ .set({ ...next, updatedAt: new Date(), lastActivityAt: new Date() })
623
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
624
+ .returning();
625
+ if (statusChange)
626
+ await tx.insert(issueStatusHistory).values({
627
+ id: uuidv7(),
628
+ workspaceId,
629
+ issueId,
630
+ projectId: current.projectId,
631
+ fromStatusId: statusChange.fromStatusId,
632
+ toStatusId: statusChange.toStatusId,
633
+ fromCategory: current.statusCategory,
634
+ toCategory: statusChange.toCategory,
635
+ actorId: principal.userId ?? null,
636
+ transitionId: null,
637
+ durationSec: null,
638
+ });
639
+ await this.notify.history(tx, {
640
+ workspaceId,
641
+ issueId,
642
+ actorId: principal.userId ?? null,
643
+ action: 'updated',
644
+ changes,
645
+ });
646
+ const issue = (await this.hydrate(tx, [row]))[0];
647
+ await this.kernel.emit(trackerEvents.issueUpdated, {
648
+ workspaceId: issue.workspaceId,
649
+ projectId: issue.projectId,
650
+ issueId: issue.id,
651
+ key: issue.key,
652
+ changes,
653
+ }, { workspaceId, actorId: principal.userId });
654
+ if (assigneesChanged) {
655
+ const gained = added(currentAssignees, assignees);
656
+ await this.kernel.emit(trackerEvents.issueAssigned, {
657
+ workspaceId: issue.workspaceId,
658
+ projectId: issue.projectId,
659
+ issueId: issue.id,
660
+ key: issue.key,
661
+ assigneeIds: issue.assigneeIds,
662
+ added: gained,
663
+ removed: removed(currentAssignees, assignees),
664
+ }, { workspaceId, actorId: principal.userId });
665
+ await this.notify.notify({
666
+ workspaceId,
667
+ userIds: gained,
668
+ type: 'tracker.issue.assigned',
669
+ title: `${issue.key} assigned to you`,
670
+ body: issue.title,
671
+ object: objectRef(issue.id),
672
+ url: issueUrl(issue.key),
673
+ groupKey: issue.id,
674
+ actorId: principal.userId,
675
+ exclude: [principal.userId],
676
+ });
677
+ }
678
+ if (patch.description !== undefined)
679
+ await this.notify.notify({
680
+ workspaceId,
681
+ userIds: extractMentions(patch.description),
682
+ type: 'tracker.issue.mentioned',
683
+ title: `You were mentioned in ${issue.key}`,
684
+ body: preview(issue.descriptionText),
685
+ object: objectRef(issue.id),
686
+ url: issueUrl(issue.key),
687
+ groupKey: issue.id,
688
+ actorId: principal.userId,
689
+ exclude: [principal.userId],
690
+ });
691
+ await this.notify.change(workspaceId, 'issue', issue.id, 'updated', {
692
+ scope: { projectId: issue.projectId },
693
+ });
694
+ await this.reindex(issue);
695
+ return issue;
696
+ }
697
+ /** Each issue is updated in its own transaction so one rejection does not undo the rest. */
698
+ async bulkUpdate(principal, workspaceId, ids, patch) {
699
+ const results = [];
700
+ for (const id of ids) {
701
+ try {
702
+ await this.kernel.database.withWorkspace(workspaceId, (tx) => this.update(tx, principal, workspaceId, id, patch), { userId: principal.userId });
703
+ results.push({ id, ok: true });
704
+ }
705
+ catch (err) {
706
+ const kern = err instanceof KernError ? err : null;
707
+ results.push({
708
+ id,
709
+ ok: false,
710
+ error: {
711
+ code: kern?.code ?? 'INTERNAL',
712
+ message: err instanceof Error ? err.message : String(err),
713
+ },
714
+ });
715
+ }
716
+ }
717
+ return {
718
+ results,
719
+ succeeded: results.filter((r) => r.ok).length,
720
+ failed: results.filter((r) => !r.ok).length,
721
+ };
722
+ }
723
+ // ------------------------------------------------------------------ lifecycle
724
+ async archive(tx, principal, workspaceId, issueId, archived) {
725
+ const current = await this.row(tx, workspaceId, issueId);
726
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.archive');
727
+ const [row] = await tx
728
+ .update(issues)
729
+ .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
730
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)))
731
+ .returning();
732
+ await this.notify.history(tx, {
733
+ workspaceId,
734
+ issueId,
735
+ actorId: principal.userId ?? null,
736
+ action: archived ? 'archived' : 'unarchived',
737
+ });
738
+ const issue = (await this.hydrate(tx, [row]))[0];
739
+ await this.kernel.emit(trackerEvents.issueArchived, {
740
+ workspaceId: issue.workspaceId,
741
+ projectId: issue.projectId,
742
+ issueId: issue.id,
743
+ key: issue.key,
744
+ archived,
745
+ }, { workspaceId, actorId: principal.userId });
746
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
747
+ scope: { projectId: issue.projectId },
748
+ });
749
+ if (archived)
750
+ await this.notify.unindex(workspaceId, 'issue', [issueId]);
751
+ else
752
+ await this.reindex(issue);
753
+ return issue;
754
+ }
755
+ async delete(tx, principal, workspaceId, issueId) {
756
+ const current = await this.row(tx, workspaceId, issueId);
757
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.delete_any');
758
+ await tx.update(issues).set({ parentId: null }).where(eq(issues.parentId, issueId));
759
+ await tx.delete(comments).where(eq(comments.issueId, issueId));
760
+ await tx.delete(attachments).where(eq(attachments.issueId, issueId));
761
+ await tx.delete(links).where(eq(links.issueId, issueId));
762
+ await tx.delete(relations).where(eq(relations.fromIssueId, issueId));
763
+ await tx.delete(relations).where(eq(relations.toIssueId, issueId));
764
+ await tx.delete(worklogs).where(eq(worklogs.issueId, issueId));
765
+ await tx.delete(timers).where(eq(timers.issueId, issueId));
766
+ await tx.delete(issueApprovals).where(eq(issueApprovals.issueId, issueId));
767
+ await tx.delete(issueStatusHistory).where(eq(issueStatusHistory.issueId, issueId));
768
+ await tx.delete(issueHistory).where(eq(issueHistory.issueId, issueId));
769
+ await tx.delete(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
770
+ await this.kernel.emit(trackerEvents.issueDeleted, {
771
+ workspaceId: workspaceId,
772
+ projectId: current.projectId,
773
+ issueId,
774
+ key: current.key,
775
+ }, { workspaceId, actorId: principal.userId });
776
+ await this.notify.unindex(workspaceId, 'issue', [issueId]);
777
+ await this.notify.change(workspaceId, 'issue', issueId, 'deleted', {
778
+ scope: { projectId: current.projectId },
779
+ });
780
+ }
781
+ /** Move to another project: the issue is re-keyed and its sub-items follow. */
782
+ async move(tx, principal, workspaceId, issueId, targetProjectId) {
783
+ const current = await this.row(tx, workspaceId, issueId);
784
+ if (current.projectId === targetProjectId)
785
+ return (await this.hydrate(tx, [current]))[0];
786
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.edit_any');
787
+ const target = await this.access.requireProject(tx, principal, workspaceId, targetProjectId, 'tracker.issue.create');
788
+ const moved = [];
789
+ const relocate = async (row) => {
790
+ const number = await this.allocateNumber(tx, workspaceId, target.id);
791
+ const type = await this.matchType(tx, workspaceId, target, row.typeId);
792
+ const { definition } = await this.config.workflowFor(tx, target, type);
793
+ const status = definition.statuses.find((s) => s.id === row.statusId) ?? initialStatus(definition);
794
+ const [updated] = await tx
795
+ .update(issues)
796
+ .set({
797
+ projectId: target.id,
798
+ key: `${target.key}-${number}`,
799
+ number,
800
+ typeId: type,
801
+ statusId: status.id,
802
+ statusCategory: status.category,
803
+ // planning objects are project-scoped and cannot travel with the issue
804
+ cycleId: null,
805
+ milestoneId: null,
806
+ componentIds: [],
807
+ versionIds: [],
808
+ affectsVersionIds: [],
809
+ rank: await this.rankFor(tx, workspaceId, target.id),
810
+ updatedAt: new Date(),
811
+ })
812
+ .where(eq(issues.id, row.id))
813
+ .returning();
814
+ moved.push(row.id);
815
+ const children = await tx.select().from(issues).where(eq(issues.parentId, row.id));
816
+ for (const child of children)
817
+ await relocate(child);
818
+ return updated;
819
+ };
820
+ const row = await relocate(current);
821
+ await this.notify.history(tx, {
822
+ workspaceId,
823
+ issueId,
824
+ actorId: principal.userId ?? null,
825
+ action: 'moved',
826
+ changes: [{ field: 'projectId', from: current.projectId, to: target.id }],
827
+ data: { fromKey: current.key, toKey: row.key },
828
+ });
829
+ const issue = (await this.hydrate(tx, [row]))[0];
830
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
831
+ scope: { projectId: target.id },
832
+ });
833
+ await this.reindex(issue);
834
+ return issue;
835
+ }
836
+ async matchType(tx, workspaceId, target, typeId) {
837
+ const source = await this.config.getType(tx, workspaceId, typeId);
838
+ const candidates = await this.config.listTypes(tx, workspaceId, { projectId: target.id });
839
+ const sameKey = candidates.find((t) => t.key === source.key);
840
+ if (sameKey)
841
+ return sameKey.id;
842
+ const sameLevel = candidates.find((t) => t.level === source.level);
843
+ if (sameLevel)
844
+ return sameLevel.id;
845
+ return (await this.config.defaultType(tx, workspaceId, target.id)).id;
846
+ }
847
+ async rank(tx, principal, workspaceId, issueId, afterId, beforeId) {
848
+ const current = await this.row(tx, workspaceId, issueId);
849
+ await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
850
+ reporterId: current.reporterId,
851
+ creatorId: current.creatorId,
852
+ assigneeIds: current.assigneeIds ?? [],
853
+ });
854
+ const rank = await this.rankBetweenIssues(tx, workspaceId, afterId ?? null, beforeId ?? null);
855
+ await tx
856
+ .update(issues)
857
+ .set({ rank, updatedAt: new Date() })
858
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
859
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated', {
860
+ patch: { rank },
861
+ scope: { projectId: current.projectId },
862
+ });
863
+ return { id: issueId, rank };
864
+ }
865
+ // ------------------------------------------------------------------ watchers
866
+ async setWatcher(tx, principal, workspaceId, issueId, userId, watching) {
867
+ const current = await this.row(tx, workspaceId, issueId);
868
+ // Watching yourself only needs to be able to see the issue. Adding or removing *someone else*
869
+ // changes what lands in their inbox, so it takes the permission that manages watchers.
870
+ const permission = userId === principal.userId ? 'tracker.issue.view' : 'tracker.issue.manage_watchers';
871
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, permission);
872
+ const existing = current.watcherIds ?? [];
873
+ const watcherIds = watching ? uniq([...existing, userId]) : existing.filter((id) => id !== userId);
874
+ await tx
875
+ .update(issues)
876
+ .set({ watcherIds, updatedAt: new Date() })
877
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
878
+ return { watcherIds };
879
+ }
880
+ // ------------------------------------------------------------------ history
881
+ async history(tx, principal, workspaceId, issueId, limit, cursor) {
882
+ const current = await this.row(tx, workspaceId, issueId);
883
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
884
+ const filters = [eq(issueHistory.workspaceId, workspaceId), eq(issueHistory.issueId, issueId)];
885
+ if (cursor)
886
+ filters.push(lt(issueHistory.id, cursor));
887
+ const rows = await tx
888
+ .select()
889
+ .from(issueHistory)
890
+ .where(and(...filters))
891
+ .orderBy(desc(issueHistory.id))
892
+ .limit(limit + 1);
893
+ const items = rows.slice(0, limit).map(toHistoryEntry);
894
+ const statusRows = await tx
895
+ .select()
896
+ .from(issueStatusHistory)
897
+ .where(and(eq(issueStatusHistory.workspaceId, workspaceId), eq(issueStatusHistory.issueId, issueId)))
898
+ .orderBy(asc(issueStatusHistory.occurredAt));
899
+ return {
900
+ items,
901
+ statusHistory: statusRows.map(toStatusHistoryEntry),
902
+ nextCursor: rows.length > limit ? (items.at(-1)?.id ?? null) : null,
903
+ };
904
+ }
905
+ // ------------------------------------------------------------------ relations
906
+ async listRelations(tx, principal, workspaceId, issueId) {
907
+ const current = await this.row(tx, workspaceId, issueId);
908
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
909
+ return this.relationViews(tx, workspaceId, issueId);
910
+ }
911
+ async relationViews(tx, workspaceId, issueId) {
912
+ const rows = await tx
913
+ .select()
914
+ .from(relations)
915
+ .where(and(eq(relations.workspaceId, workspaceId), eq(relations.fromIssueId, issueId)))
916
+ .orderBy(asc(relations.createdAt));
917
+ if (!rows.length)
918
+ return [];
919
+ const targets = await tx
920
+ .select()
921
+ .from(issues)
922
+ .where(and(eq(issues.workspaceId, workspaceId), inArray(issues.id, rows.map((r) => r.toIssueId))));
923
+ const hydrated = await this.hydrate(tx, targets);
924
+ const byId = new Map(hydrated.map((i) => [i.id, toIssueSummary(i)]));
925
+ return rows
926
+ .filter((r) => byId.has(r.toIssueId))
927
+ .map((r) => ({
928
+ id: r.id,
929
+ type: r.type,
930
+ issue: byId.get(r.toIssueId),
931
+ createdAt: r.createdAt.toISOString(),
932
+ }));
933
+ }
934
+ /** Relations are stored as a pair of rows so both issues can list theirs with one indexed lookup. */
935
+ async createRelation(tx, principal, workspaceId, issueId, type, targetIssueId) {
936
+ if (issueId === targetIssueId)
937
+ throw KernError.badRequest('An issue cannot relate to itself');
938
+ const current = await this.row(tx, workspaceId, issueId);
939
+ const target = await this.row(tx, workspaceId, targetIssueId);
940
+ await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
941
+ reporterId: current.reporterId,
942
+ creatorId: current.creatorId,
943
+ assigneeIds: current.assigneeIds ?? [],
944
+ });
945
+ await this.access.requireProject(tx, principal, workspaceId, target.projectId, 'tracker.issue.view');
946
+ const pairId = uuidv7();
947
+ await tx
948
+ .insert(relations)
949
+ .values([
950
+ {
951
+ id: pairId,
952
+ workspaceId,
953
+ type,
954
+ fromIssueId: issueId,
955
+ toIssueId: targetIssueId,
956
+ createdBy: principal.userId ?? null,
957
+ },
958
+ {
959
+ id: uuidv7(),
960
+ workspaceId,
961
+ type: RELATION_INVERSE[type],
962
+ fromIssueId: targetIssueId,
963
+ toIssueId: issueId,
964
+ createdBy: principal.userId ?? null,
965
+ },
966
+ ])
967
+ .onConflictDoNothing();
968
+ await this.notify.history(tx, {
969
+ workspaceId,
970
+ issueId,
971
+ actorId: principal.userId ?? null,
972
+ action: 'relation_added',
973
+ data: { type, targetIssueId, targetKey: target.key },
974
+ });
975
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated');
976
+ await this.notify.change(workspaceId, 'issue', targetIssueId, 'updated');
977
+ return this.relationViews(tx, workspaceId, issueId);
978
+ }
979
+ async deleteRelation(tx, principal, workspaceId, relationId) {
980
+ const [row] = await tx
981
+ .select()
982
+ .from(relations)
983
+ .where(and(eq(relations.workspaceId, workspaceId), eq(relations.id, relationId)))
984
+ .limit(1);
985
+ if (!row)
986
+ throw KernError.notFound('Relation');
987
+ const source = await this.row(tx, workspaceId, row.fromIssueId);
988
+ await this.access.requireEditIssue(principal, workspaceId, source.projectId, {
989
+ reporterId: source.reporterId,
990
+ creatorId: source.creatorId,
991
+ assigneeIds: source.assigneeIds ?? [],
992
+ });
993
+ await tx.delete(relations).where(eq(relations.id, relationId));
994
+ await tx
995
+ .delete(relations)
996
+ .where(and(eq(relations.workspaceId, workspaceId), eq(relations.fromIssueId, row.toIssueId), eq(relations.toIssueId, row.fromIssueId), eq(relations.type, RELATION_INVERSE[row.type])));
997
+ await this.notify.change(workspaceId, 'issue', row.fromIssueId, 'updated');
998
+ await this.notify.change(workspaceId, 'issue', row.toIssueId, 'updated');
999
+ }
1000
+ // ------------------------------------------------------------------ attachments & links
1001
+ async listAttachments(tx, principal, workspaceId, issueId) {
1002
+ const current = await this.row(tx, workspaceId, issueId);
1003
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
1004
+ const rows = await tx
1005
+ .select()
1006
+ .from(attachments)
1007
+ .where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.issueId, issueId)))
1008
+ .orderBy(asc(attachments.createdAt));
1009
+ return rows.map(toAttachment);
1010
+ }
1011
+ async attachFiles(tx, workspaceId, issueId, fileIds, uploadedBy) {
1012
+ const rows = [];
1013
+ for (const fileId of uniq(fileIds)) {
1014
+ const file = await this.kernel
1015
+ .call('core.files.get', { id: fileId })
1016
+ .catch(() => null);
1017
+ rows.push({
1018
+ id: uuidv7(),
1019
+ workspaceId,
1020
+ issueId,
1021
+ fileId,
1022
+ name: file?.name ?? 'attachment',
1023
+ mimeType: file?.mimeType ?? 'application/octet-stream',
1024
+ size: file?.size ?? 0,
1025
+ uploadedBy,
1026
+ });
1027
+ }
1028
+ if (!rows.length)
1029
+ return [];
1030
+ const inserted = await tx
1031
+ .insert(attachments)
1032
+ .values(rows)
1033
+ .onConflictDoNothing({ target: [attachments.issueId, attachments.fileId] })
1034
+ .returning();
1035
+ await this.refreshAttachmentCount(tx, workspaceId, issueId);
1036
+ return inserted.map(toAttachment);
1037
+ }
1038
+ async addAttachments(tx, principal, workspaceId, issueId, fileIds) {
1039
+ const current = await this.row(tx, workspaceId, issueId);
1040
+ await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
1041
+ reporterId: current.reporterId,
1042
+ creatorId: current.creatorId,
1043
+ assigneeIds: current.assigneeIds ?? [],
1044
+ });
1045
+ const created = await this.attachFiles(tx, workspaceId, issueId, fileIds, principal.userId ?? null);
1046
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated');
1047
+ return created;
1048
+ }
1049
+ async removeAttachment(tx, principal, workspaceId, attachmentId) {
1050
+ const [row] = await tx
1051
+ .select()
1052
+ .from(attachments)
1053
+ .where(and(eq(attachments.workspaceId, workspaceId), eq(attachments.id, attachmentId)))
1054
+ .limit(1);
1055
+ if (!row)
1056
+ throw KernError.notFound('Attachment');
1057
+ const issue = await this.row(tx, workspaceId, row.issueId);
1058
+ await this.access.requireEditIssue(principal, workspaceId, issue.projectId, {
1059
+ reporterId: issue.reporterId,
1060
+ creatorId: issue.creatorId,
1061
+ assigneeIds: issue.assigneeIds ?? [],
1062
+ });
1063
+ await tx.delete(attachments).where(eq(attachments.id, attachmentId));
1064
+ await this.refreshAttachmentCount(tx, workspaceId, row.issueId);
1065
+ await this.notify.change(workspaceId, 'issue', row.issueId, 'updated');
1066
+ }
1067
+ async refreshAttachmentCount(tx, workspaceId, issueId) {
1068
+ await tx
1069
+ .update(issues)
1070
+ .set({
1071
+ attachmentCount: sql `(select count(*) from ${attachments} where ${attachments.issueId} = ${issueId})`,
1072
+ })
1073
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.id, issueId)));
1074
+ }
1075
+ async listLinks(tx, principal, workspaceId, issueId) {
1076
+ const current = await this.row(tx, workspaceId, issueId);
1077
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.issue.view');
1078
+ const rows = await tx
1079
+ .select()
1080
+ .from(links)
1081
+ .where(and(eq(links.workspaceId, workspaceId), eq(links.issueId, issueId)))
1082
+ .orderBy(asc(links.createdAt));
1083
+ return rows.map(toLink);
1084
+ }
1085
+ async addLink(tx, principal, workspaceId, issueId, input) {
1086
+ const current = await this.row(tx, workspaceId, issueId);
1087
+ await this.access.requireEditIssue(principal, workspaceId, current.projectId, {
1088
+ reporterId: current.reporterId,
1089
+ creatorId: current.creatorId,
1090
+ assigneeIds: current.assigneeIds ?? [],
1091
+ });
1092
+ const [row] = await tx
1093
+ .insert(links)
1094
+ .values({
1095
+ id: uuidv7(),
1096
+ workspaceId,
1097
+ issueId,
1098
+ url: input.url,
1099
+ title: input.title ?? null,
1100
+ kind: input.kind,
1101
+ createdBy: principal.userId ?? null,
1102
+ })
1103
+ .returning();
1104
+ await this.notify.change(workspaceId, 'issue', issueId, 'updated');
1105
+ return toLink(row);
1106
+ }
1107
+ async removeLink(tx, principal, workspaceId, linkId) {
1108
+ const [row] = await tx
1109
+ .select()
1110
+ .from(links)
1111
+ .where(and(eq(links.workspaceId, workspaceId), eq(links.id, linkId)))
1112
+ .limit(1);
1113
+ if (!row)
1114
+ throw KernError.notFound('Link');
1115
+ const issue = await this.row(tx, workspaceId, row.issueId);
1116
+ await this.access.requireEditIssue(principal, workspaceId, issue.projectId, {
1117
+ reporterId: issue.reporterId,
1118
+ creatorId: issue.creatorId,
1119
+ assigneeIds: issue.assigneeIds ?? [],
1120
+ });
1121
+ await tx.delete(links).where(eq(links.id, linkId));
1122
+ await this.notify.change(workspaceId, 'issue', row.issueId, 'updated');
1123
+ }
1124
+ // ------------------------------------------------------------------ issue templates
1125
+ async listTemplates(tx, principal, workspaceId, projectId) {
1126
+ const filters = [eq(issueTemplates.workspaceId, workspaceId)];
1127
+ if (projectId) {
1128
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.issue.view');
1129
+ filters.push(sql `(${issueTemplates.projectId} is null or ${issueTemplates.projectId} = ${projectId})`);
1130
+ }
1131
+ else {
1132
+ // Without a project the answer is "everything you could use": templates shared across the
1133
+ // workspace, plus those belonging to projects this caller can actually see.
1134
+ const visible = await this.access.visibleProjectIds(tx, principal, workspaceId);
1135
+ filters.push(visible.length
1136
+ ? or(isNull(issueTemplates.projectId), inArray(issueTemplates.projectId, visible))
1137
+ : isNull(issueTemplates.projectId));
1138
+ }
1139
+ const rows = await tx
1140
+ .select()
1141
+ .from(issueTemplates)
1142
+ .where(and(...filters))
1143
+ .orderBy(asc(issueTemplates.name));
1144
+ return rows.map(toIssueTemplate);
1145
+ }
1146
+ /**
1147
+ * Issue templates and recurring rules are project configuration, not content: they decide what
1148
+ * everyone else's issues look like. They take the same permission as managing the project, at
1149
+ * project scope when they belong to one and at workspace scope when they are shared.
1150
+ */
1151
+ async recurringRow(tx, workspaceId, id) {
1152
+ const [row] = await tx
1153
+ .select()
1154
+ .from(recurringIssues)
1155
+ .where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)))
1156
+ .limit(1);
1157
+ if (!row)
1158
+ throw KernError.notFound('Recurring issue');
1159
+ return row;
1160
+ }
1161
+ async templateRow(tx, workspaceId, id) {
1162
+ const [row] = await tx
1163
+ .select()
1164
+ .from(issueTemplates)
1165
+ .where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)))
1166
+ .limit(1);
1167
+ if (!row)
1168
+ throw KernError.notFound('Issue template');
1169
+ return row;
1170
+ }
1171
+ async requireTemplateManage(tx, principal, workspaceId, projectId) {
1172
+ if (projectId)
1173
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
1174
+ else
1175
+ await this.access.requireWorkspace(principal, 'tracker.project.manage', workspaceId);
1176
+ }
1177
+ async createTemplate(tx, principal, workspaceId, input) {
1178
+ await this.requireTemplateManage(tx, principal, workspaceId, input.projectId ?? null);
1179
+ const [row] = await tx
1180
+ .insert(issueTemplates)
1181
+ .values({
1182
+ id: uuidv7(),
1183
+ workspaceId,
1184
+ projectId: input.projectId ?? null,
1185
+ name: input.name,
1186
+ description: input.description ?? null,
1187
+ typeId: input.typeId ?? null,
1188
+ defaults: input.defaults ?? {},
1189
+ subItems: input.subItems ?? [],
1190
+ createdBy: principal.userId ?? null,
1191
+ })
1192
+ .returning();
1193
+ return toIssueTemplate(row);
1194
+ }
1195
+ async updateTemplate(tx, principal, workspaceId, id, patch) {
1196
+ const current = await this.templateRow(tx, workspaceId, id);
1197
+ await this.requireTemplateManage(tx, principal, workspaceId, current.projectId);
1198
+ // moving a template into or out of a project needs the permission on the destination too
1199
+ if (patch.projectId !== undefined && patch.projectId !== current.projectId)
1200
+ await this.requireTemplateManage(tx, principal, workspaceId, patch.projectId ?? null);
1201
+ const [row] = await tx
1202
+ .update(issueTemplates)
1203
+ .set({
1204
+ ...(patch.name === undefined ? {} : { name: patch.name }),
1205
+ ...(patch.description === undefined ? {} : { description: patch.description }),
1206
+ ...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
1207
+ ...(patch.typeId === undefined ? {} : { typeId: patch.typeId }),
1208
+ ...(patch.defaults === undefined ? {} : { defaults: patch.defaults }),
1209
+ ...(patch.subItems === undefined ? {} : { subItems: patch.subItems }),
1210
+ updatedAt: new Date(),
1211
+ })
1212
+ .where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)))
1213
+ .returning();
1214
+ if (!row)
1215
+ throw KernError.notFound('Issue template');
1216
+ return toIssueTemplate(row);
1217
+ }
1218
+ async deleteTemplate(tx, principal, workspaceId, id) {
1219
+ const current = await this.templateRow(tx, workspaceId, id);
1220
+ await this.requireTemplateManage(tx, principal, workspaceId, current.projectId);
1221
+ await tx
1222
+ .delete(issueTemplates)
1223
+ .where(and(eq(issueTemplates.workspaceId, workspaceId), eq(issueTemplates.id, id)));
1224
+ }
1225
+ // ------------------------------------------------------------------ recurring issues
1226
+ async listRecurring(tx, principal, workspaceId, projectId) {
1227
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.issue.view');
1228
+ const rows = await tx
1229
+ .select()
1230
+ .from(recurringIssues)
1231
+ .where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.projectId, projectId)))
1232
+ .orderBy(asc(recurringIssues.name));
1233
+ return rows.map(toRecurringIssue);
1234
+ }
1235
+ async createRecurring(tx, principal, workspaceId, projectId, input) {
1236
+ await this.access.requireProject(tx, principal, workspaceId, projectId, 'tracker.project.manage');
1237
+ const [row] = await tx
1238
+ .insert(recurringIssues)
1239
+ .values({
1240
+ id: uuidv7(),
1241
+ workspaceId,
1242
+ projectId,
1243
+ name: input.name,
1244
+ rule: input.rule,
1245
+ defaults: input.defaults,
1246
+ enabled: input.enabled ?? true,
1247
+ nextRunAt: nextRun(input.rule, new Date()),
1248
+ createdBy: principal.userId ?? null,
1249
+ })
1250
+ .returning();
1251
+ return toRecurringIssue(row);
1252
+ }
1253
+ async updateRecurring(tx, principal, workspaceId, id, patch) {
1254
+ const current = await this.recurringRow(tx, workspaceId, id);
1255
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
1256
+ const [row] = await tx
1257
+ .update(recurringIssues)
1258
+ .set({
1259
+ ...(patch.name === undefined ? {} : { name: patch.name }),
1260
+ ...(patch.rule === undefined ? {} : { rule: patch.rule, nextRunAt: nextRun(patch.rule, new Date()) }),
1261
+ ...(patch.defaults === undefined ? {} : { defaults: patch.defaults }),
1262
+ ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
1263
+ updatedAt: new Date(),
1264
+ })
1265
+ .where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)))
1266
+ .returning();
1267
+ if (!row)
1268
+ throw KernError.notFound('Recurring issue');
1269
+ return toRecurringIssue(row);
1270
+ }
1271
+ async deleteRecurring(tx, principal, workspaceId, id) {
1272
+ const current = await this.recurringRow(tx, workspaceId, id);
1273
+ await this.access.requireProject(tx, principal, workspaceId, current.projectId, 'tracker.project.manage');
1274
+ await tx
1275
+ .delete(recurringIssues)
1276
+ .where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.id, id)));
1277
+ }
1278
+ /** Create the issues whose recurrence is due. Called by the `tracker.recurring` job. */
1279
+ async runDueRecurring(workspaceId, now = new Date()) {
1280
+ const due = await this.kernel.database.withWorkspace(workspaceId, (tx) => tx
1281
+ .select()
1282
+ .from(recurringIssues)
1283
+ .where(and(eq(recurringIssues.workspaceId, workspaceId), eq(recurringIssues.enabled, true), sql `${recurringIssues.nextRunAt} is not null and ${recurringIssues.nextRunAt} <= ${now.toISOString()}`))
1284
+ .limit(200));
1285
+ let created = 0;
1286
+ for (const rule of due) {
1287
+ try {
1288
+ await this.kernel.database.withWorkspace(workspaceId, async (tx) => {
1289
+ const defaults = rule.defaults ?? {};
1290
+ const issue = await this.create(tx, this.kernel.system, workspaceId, {
1291
+ ...defaults,
1292
+ projectId: rule.projectId,
1293
+ title: defaults.title ?? rule.name,
1294
+ }, { source: 'recurring', system: true });
1295
+ await tx
1296
+ .update(recurringIssues)
1297
+ .set({
1298
+ lastRunAt: now,
1299
+ lastIssueId: issue.id,
1300
+ runCount: sql `${recurringIssues.runCount} + 1`,
1301
+ nextRunAt: nextRun(rule.rule, now),
1302
+ updatedAt: now,
1303
+ })
1304
+ .where(eq(recurringIssues.id, rule.id));
1305
+ });
1306
+ created++;
1307
+ }
1308
+ catch (err) {
1309
+ this.kernel.log.warn({ err: String(err), rule: rule.id }, 'tracker: recurring issue failed');
1310
+ }
1311
+ }
1312
+ return created;
1313
+ }
1314
+ /** Issues whose parent is `issueId` (used by the workflow `subtasks.allDone` condition). */
1315
+ async subItems(tx, workspaceId, issueId) {
1316
+ return tx
1317
+ .select({ id: issues.id, statusCategory: issues.statusCategory })
1318
+ .from(issues)
1319
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.parentId, issueId), isNull(issues.archivedAt)));
1320
+ }
1321
+ /** Everyone who should hear about a change to this issue. */
1322
+ watchersOf(row) {
1323
+ return uniq([...(row.watcherIds ?? []), ...(row.assigneeIds ?? []), row.reporterId ?? ''].filter(Boolean));
1324
+ }
1325
+ isResolved(category) {
1326
+ return RESOLVED_CATEGORIES.has(category);
1327
+ }
1328
+ /** Projects table accessor shared with the reports service. */
1329
+ projectsTable() {
1330
+ return projects;
1331
+ }
1332
+ typesTable() {
1333
+ return workItemTypes;
1334
+ }
1335
+ }
1336
+ /** Next occurrence of a recurrence rule after `from`. */
1337
+ export function nextRun(rule, from) {
1338
+ const [hh, mm] = (rule.at ?? '09:00').split(':').map(Number);
1339
+ const base = new Date(from.getTime());
1340
+ base.setUTCSeconds(0, 0);
1341
+ const at = (d) => {
1342
+ const copy = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hh ?? 9, mm ?? 0));
1343
+ return copy;
1344
+ };
1345
+ const interval = Math.max(1, rule.interval ?? 1);
1346
+ let candidate = at(base);
1347
+ if (candidate <= from)
1348
+ candidate = new Date(candidate.getTime() + 86_400_000);
1349
+ if (rule.freq === 'daily') {
1350
+ const days = Math.ceil((candidate.getTime() - at(base).getTime()) / 86_400_000);
1351
+ if (days % interval !== 0)
1352
+ candidate = new Date(candidate.getTime() + (interval - (days % interval)) * 86_400_000);
1353
+ }
1354
+ else if (rule.freq === 'weekly') {
1355
+ const weekdays = rule.byWeekday?.length ? rule.byWeekday : [candidate.getUTCDay()];
1356
+ for (let i = 0; i < 7 * interval + 7; i++) {
1357
+ const probe = new Date(candidate.getTime() + i * 86_400_000);
1358
+ if (weekdays.includes(probe.getUTCDay())) {
1359
+ candidate = probe;
1360
+ break;
1361
+ }
1362
+ }
1363
+ }
1364
+ else if (rule.freq === 'monthly') {
1365
+ const day = rule.byMonthDay ?? candidate.getUTCDate();
1366
+ let probe = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), day, hh ?? 9, mm ?? 0));
1367
+ if (probe <= from)
1368
+ probe = new Date(Date.UTC(probe.getUTCFullYear(), probe.getUTCMonth() + interval, day, hh ?? 9, mm ?? 0));
1369
+ candidate = probe;
1370
+ }
1371
+ else if (rule.freq === 'yearly') {
1372
+ let probe = new Date(Date.UTC(candidate.getUTCFullYear(), candidate.getUTCMonth(), candidate.getUTCDate(), hh ?? 9, mm ?? 0));
1373
+ if (probe <= from)
1374
+ probe = new Date(Date.UTC(probe.getUTCFullYear() + interval, probe.getUTCMonth(), probe.getUTCDate(), hh ?? 9, mm ?? 0));
1375
+ candidate = probe;
1376
+ }
1377
+ if (rule.until && candidate > new Date(rule.until))
1378
+ return null;
1379
+ return candidate;
1380
+ }
1381
+ //# sourceMappingURL=issues.js.map