@checkstack/incident-backend 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { eq, and, inArray, ne, isNotNull } from "drizzle-orm";
2
- import type { AdvisoryLockService, SafeDatabase } from "@checkstack/backend-api";
2
+ import { withScopedTransaction } from "@checkstack/backend-api";
3
+ import type {
4
+ AdvisoryLockService,
5
+ SafeDatabase,
6
+ ScopedQueryRunner,
7
+ } from "@checkstack/backend-api";
3
8
  import * as schema from "./schema";
4
9
  import {
5
10
  incidents,
@@ -13,20 +18,54 @@ import type {
13
18
  IncidentUpdate,
14
19
  IncidentLink,
15
20
  AddIncidentLinkInput,
21
+ UpdateIncidentLinkInput,
16
22
  CreateIncidentInput,
17
23
  UpdateIncidentInput,
18
24
  AddIncidentUpdateInput,
25
+ EditIncidentUpdateInput,
19
26
  IncidentStatus,
20
27
  IncidentSeverity,
28
+ IncidentUpdateEditSnapshot,
21
29
  SystemHealthOverride,
22
30
  } from "@checkstack/incident-common";
31
+ import { desc } from "drizzle-orm";
23
32
 
24
33
  type Db = SafeDatabase<typeof schema>;
25
34
 
35
+ /** The transaction handle drizzle hands to `db.transaction(async (tx) => ...)`. */
36
+ type IncidentTx = Parameters<Parameters<Db["transaction"]>[0]>[0];
37
+
26
38
  function generateId(): string {
27
39
  return crypto.randomUUID();
28
40
  }
29
41
 
42
+ /**
43
+ * Derive an incident's header status from its timeline: the `statusChange` of
44
+ * the MOST RECENT status-bearing update (message-only updates are ignored), so
45
+ * the header always follows the newest update that actually declared a status.
46
+ * Returns undefined when no update carries a status (nothing to derive). Runs on
47
+ * the passed `tx` so an edit/delete re-derivation is atomic with the write that
48
+ * triggered it. Centralized so `editUpdate` and `deleteUpdate` can never
49
+ * diverge on how "current status" is resolved.
50
+ */
51
+ async function deriveStatusFromTimeline(
52
+ tx: IncidentTx,
53
+ incidentId: string,
54
+ ): Promise<IncidentStatus | undefined> {
55
+ const [latest] = await tx
56
+ .select({ statusChange: incidentUpdates.statusChange })
57
+ .from(incidentUpdates)
58
+ .where(
59
+ and(
60
+ eq(incidentUpdates.incidentId, incidentId),
61
+ isNotNull(incidentUpdates.statusChange),
62
+ ),
63
+ )
64
+ .orderBy(desc(incidentUpdates.createdAt), desc(incidentUpdates.id))
65
+ .limit(1);
66
+ return latest?.statusChange ?? undefined;
67
+ }
68
+
30
69
  export class IncidentService {
31
70
  constructor(
32
71
  private db: Db,
@@ -41,93 +80,124 @@ export class IncidentService {
41
80
  systemId?: string;
42
81
  includeResolved?: boolean;
43
82
  }): Promise<IncidentWithSystems[]> {
44
- let incidentRows;
45
-
46
- if (filters?.systemId) {
47
- // Filter by system - need to join
48
- const systemIncidentIds = await this.db
49
- .select({ incidentId: incidentSystems.incidentId })
50
- .from(incidentSystems)
51
- .where(eq(incidentSystems.systemId, filters.systemId));
52
-
53
- const ids = systemIncidentIds.map((r) => r.incidentId);
54
- if (ids.length === 0) return [];
55
-
56
- const statusFilter = filters.status
57
- ? eq(incidents.status, filters.status)
58
- : filters.includeResolved
59
- ? undefined
60
- : ne(incidents.status, "resolved");
83
+ const statusFilter = filters?.status
84
+ ? eq(incidents.status, filters.status)
85
+ : filters?.includeResolved
86
+ ? undefined
87
+ : ne(incidents.status, "resolved");
88
+
89
+ // One SET LOCAL for the whole read fan-out. System associations are fetched
90
+ // in a SINGLE set-based `inArray` query and grouped in JS, instead of the
91
+ // former per-row N+1 loop (mirrors `getManyEntityStates`).
92
+ return withScopedTransaction(this.db, async (tx) => {
93
+ let incidentRows;
94
+
95
+ if (filters?.systemId) {
96
+ // Filter by system - need to join
97
+ const systemIncidentIds = await tx
98
+ .select({ incidentId: incidentSystems.incidentId })
99
+ .from(incidentSystems)
100
+ .where(eq(incidentSystems.systemId, filters.systemId));
101
+
102
+ const ids = systemIncidentIds.map((r) => r.incidentId);
103
+ if (ids.length === 0) return [];
104
+
105
+ incidentRows = await tx
106
+ .select()
107
+ .from(incidents)
108
+ .where(and(inArray(incidents.id, ids), statusFilter));
109
+ } else {
110
+ incidentRows = await tx.select().from(incidents).where(statusFilter);
111
+ }
61
112
 
62
- incidentRows = await this.db
63
- .select()
64
- .from(incidents)
65
- .where(and(inArray(incidents.id, ids), statusFilter));
66
- } else {
67
- const statusFilter = filters?.status
68
- ? eq(incidents.status, filters.status)
69
- : filters?.includeResolved
70
- ? undefined
71
- : ne(incidents.status, "resolved");
72
-
73
- incidentRows = await this.db.select().from(incidents).where(statusFilter);
74
- }
113
+ if (incidentRows.length === 0) return [];
75
114
 
76
- // Fetch all system associations
77
- const result: IncidentWithSystems[] = [];
78
- for (const i of incidentRows) {
79
- const systems = await this.db
80
- .select({ systemId: incidentSystems.systemId })
81
- .from(incidentSystems)
82
- .where(eq(incidentSystems.incidentId, i.id));
115
+ const systemsByIncident = await this.getSystemsByIncident(
116
+ tx,
117
+ incidentRows.map((i) => i.id),
118
+ );
83
119
 
84
- result.push({
120
+ return incidentRows.map((i) => ({
85
121
  ...i,
86
122
  description: i.description ?? undefined,
87
- systemIds: systems.map((s) => s.systemId),
88
- });
89
- }
123
+ systemIds: systemsByIncident.get(i.id) ?? [],
124
+ }));
125
+ });
126
+ }
90
127
 
91
- return result;
128
+ /**
129
+ * Read the system associations for a set of incident ids in ONE set-based
130
+ * `inArray` query and group them by incidentId. Runs on the passed runner
131
+ * (the scoped db OR a batching `tx`), so callers that already hold a
132
+ * transaction reuse its single `SET LOCAL search_path`. Replaces the former
133
+ * per-row N+1 loop used by the list/for-system reads.
134
+ */
135
+ private async getSystemsByIncident(
136
+ runner: ScopedQueryRunner<typeof schema>,
137
+ incidentIds: string[],
138
+ ): Promise<Map<string, string[]>> {
139
+ const byIncident = new Map<string, string[]>();
140
+ if (incidentIds.length === 0) return byIncident;
141
+
142
+ const systemRows = await runner
143
+ .select({
144
+ incidentId: incidentSystems.incidentId,
145
+ systemId: incidentSystems.systemId,
146
+ })
147
+ .from(incidentSystems)
148
+ .where(inArray(incidentSystems.incidentId, incidentIds));
149
+
150
+ for (const r of systemRows) {
151
+ const list = byIncident.get(r.incidentId);
152
+ if (list) list.push(r.systemId);
153
+ else byIncident.set(r.incidentId, [r.systemId]);
154
+ }
155
+ return byIncident;
92
156
  }
93
157
 
94
158
  /**
95
159
  * Get single incident with full details
96
160
  */
97
161
  async getIncident(id: string): Promise<IncidentDetail | undefined> {
98
- const [incident] = await this.db
99
- .select()
100
- .from(incidents)
101
- .where(eq(incidents.id, id));
162
+ // Batch the 4 sequential reads (incident -> systems -> updates -> links)
163
+ // behind a single `SET LOCAL search_path`.
164
+ return withScopedTransaction(this.db, async (tx) => {
165
+ const [incident] = await tx
166
+ .select()
167
+ .from(incidents)
168
+ .where(eq(incidents.id, id));
102
169
 
103
- if (!incident) return undefined;
170
+ if (!incident) return;
104
171
 
105
- const systems = await this.db
106
- .select({ systemId: incidentSystems.systemId })
107
- .from(incidentSystems)
108
- .where(eq(incidentSystems.incidentId, id));
172
+ const systems = await tx
173
+ .select({ systemId: incidentSystems.systemId })
174
+ .from(incidentSystems)
175
+ .where(eq(incidentSystems.incidentId, id));
109
176
 
110
- const updates = await this.db
111
- .select()
112
- .from(incidentUpdates)
113
- .where(eq(incidentUpdates.incidentId, id));
177
+ const updates = await tx
178
+ .select()
179
+ .from(incidentUpdates)
180
+ .where(eq(incidentUpdates.incidentId, id));
114
181
 
115
- const links = await this.db
116
- .select()
117
- .from(incidentLinks)
118
- .where(eq(incidentLinks.incidentId, id));
182
+ const links = await tx
183
+ .select()
184
+ .from(incidentLinks)
185
+ .where(eq(incidentLinks.incidentId, id));
119
186
 
120
- return {
121
- ...incident,
122
- description: incident.description ?? undefined,
123
- systemIds: systems.map((s) => s.systemId),
124
- updates: updates.map((u) => ({
125
- ...u,
126
- statusChange: u.statusChange ?? undefined,
127
- createdBy: u.createdBy ?? undefined,
128
- })),
129
- links,
130
- };
187
+ return {
188
+ ...incident,
189
+ description: incident.description ?? undefined,
190
+ systemIds: systems.map((s) => s.systemId),
191
+ updates: updates.map((u) => ({
192
+ ...u,
193
+ statusChange: u.statusChange ?? undefined,
194
+ editedAt: u.editedAt ?? undefined,
195
+ editHistory: u.editHistory ?? [],
196
+ createdBy: u.createdBy ?? undefined,
197
+ })),
198
+ links,
199
+ };
200
+ });
131
201
  }
132
202
 
133
203
  /**
@@ -146,42 +216,74 @@ export class IncidentService {
146
216
  > {
147
217
  if (ids.length === 0) return {};
148
218
 
149
- const rows = await this.db
150
- .select({
151
- id: incidents.id,
152
- status: incidents.status,
153
- severity: incidents.severity,
154
- })
155
- .from(incidents)
156
- .where(inArray(incidents.id, [...ids]));
157
- if (rows.length === 0) return {};
219
+ // Batch the incidents read + junction read behind a single `SET LOCAL`.
220
+ return withScopedTransaction(this.db, async (tx) => {
221
+ const rows = await tx
222
+ .select({
223
+ id: incidents.id,
224
+ status: incidents.status,
225
+ severity: incidents.severity,
226
+ })
227
+ .from(incidents)
228
+ .where(inArray(incidents.id, [...ids]));
229
+ if (rows.length === 0) return {};
158
230
 
159
- const presentIds = rows.map((r) => r.id);
160
- const systemRows = await this.db
161
- .select({
162
- incidentId: incidentSystems.incidentId,
163
- systemId: incidentSystems.systemId,
164
- })
165
- .from(incidentSystems)
166
- .where(inArray(incidentSystems.incidentId, presentIds));
231
+ const systemsByIncident = await this.getSystemsByIncident(
232
+ tx,
233
+ rows.map((r) => r.id),
234
+ );
167
235
 
168
- const systemsByIncident = new Map<string, string[]>();
169
- for (const r of systemRows) {
170
- const list = systemsByIncident.get(r.incidentId);
171
- if (list) list.push(r.systemId);
172
- else systemsByIncident.set(r.incidentId, [r.systemId]);
173
- }
236
+ const out: Record<
237
+ string,
238
+ {
239
+ status: IncidentStatus;
240
+ severity: IncidentSeverity;
241
+ systemIds: string[];
242
+ }
243
+ > = {};
244
+ for (const row of rows) {
245
+ out[row.id] = {
246
+ status: row.status,
247
+ severity: row.severity,
248
+ systemIds: systemsByIncident.get(row.id) ?? [],
249
+ };
250
+ }
251
+ return out;
252
+ });
253
+ }
174
254
 
175
- const out: Record<
176
- string,
177
- { status: IncidentStatus; severity: IncidentSeverity; systemIds: string[] }
178
- > = {};
179
- for (const row of rows) {
180
- out[row.id] = {
181
- status: row.status,
182
- severity: row.severity,
183
- systemIds: systemsByIncident.get(row.id) ?? [],
255
+ /**
256
+ * Bulk read of each incident's FULL update timeline (all visibilities),
257
+ * keyed by incident id, in ONE set-based `inArray` query grouped in JS -
258
+ * instead of an N+1 fan-out of {@link getIncident} the status-page widget
259
+ * used purely for `.updates`. Returns the same raw update shape `getIncident`
260
+ * produces; the ROUTER applies audience filtering + name resolution on top,
261
+ * so no visibility policy lives here. Incidents with no updates are omitted.
262
+ */
263
+ async getBulkIncidentUpdates(
264
+ incidentIds: string[],
265
+ ): Promise<Record<string, IncidentUpdate[]>> {
266
+ if (incidentIds.length === 0) return {};
267
+
268
+ const rows = await withScopedTransaction(this.db, (tx) =>
269
+ tx
270
+ .select()
271
+ .from(incidentUpdates)
272
+ .where(inArray(incidentUpdates.incidentId, incidentIds)),
273
+ );
274
+
275
+ const out: Record<string, IncidentUpdate[]> = {};
276
+ for (const u of rows) {
277
+ const mapped: IncidentUpdate = {
278
+ ...u,
279
+ statusChange: u.statusChange ?? undefined,
280
+ editedAt: u.editedAt ?? undefined,
281
+ editHistory: u.editHistory ?? [],
282
+ createdBy: u.createdBy ?? undefined,
184
283
  };
284
+ const list = out[u.incidentId];
285
+ if (list) list.push(mapped);
286
+ else out[u.incidentId] = [mapped];
185
287
  }
186
288
  return out;
187
289
  }
@@ -192,37 +294,38 @@ export class IncidentService {
192
294
  async getIncidentsForSystem(
193
295
  systemId: string,
194
296
  ): Promise<IncidentWithSystems[]> {
195
- // Get incident IDs for this system
196
- const systemIncidents = await this.db
197
- .select({ incidentId: incidentSystems.incidentId })
198
- .from(incidentSystems)
199
- .where(eq(incidentSystems.systemId, systemId));
297
+ return withScopedTransaction(this.db, async (tx) => {
298
+ // Get incident IDs for this system
299
+ const systemIncidents = await tx
300
+ .select({ incidentId: incidentSystems.incidentId })
301
+ .from(incidentSystems)
302
+ .where(eq(incidentSystems.systemId, systemId));
200
303
 
201
- const ids = systemIncidents.map((r) => r.incidentId);
202
- if (ids.length === 0) return [];
304
+ const ids = systemIncidents.map((r) => r.incidentId);
305
+ if (ids.length === 0) return [];
203
306
 
204
- // Get only non-resolved incidents
205
- const rows = await this.db
206
- .select()
207
- .from(incidents)
208
- .where(and(inArray(incidents.id, ids), ne(incidents.status, "resolved")));
307
+ // Get only non-resolved incidents
308
+ const rows = await tx
309
+ .select()
310
+ .from(incidents)
311
+ .where(
312
+ and(inArray(incidents.id, ids), ne(incidents.status, "resolved")),
313
+ );
314
+ if (rows.length === 0) return [];
209
315
 
210
- // Fetch system IDs for each
211
- const result: IncidentWithSystems[] = [];
212
- for (const i of rows) {
213
- const systems = await this.db
214
- .select({ systemId: incidentSystems.systemId })
215
- .from(incidentSystems)
216
- .where(eq(incidentSystems.incidentId, i.id));
316
+ // Fetch the full system membership for all matched incidents in ONE
317
+ // set-based query (was a per-row N+1 loop).
318
+ const systemsByIncident = await this.getSystemsByIncident(
319
+ tx,
320
+ rows.map((i) => i.id),
321
+ );
217
322
 
218
- result.push({
323
+ return rows.map((i) => ({
219
324
  ...i,
220
325
  description: i.description ?? undefined,
221
- systemIds: systems.map((s) => s.systemId),
222
- });
223
- }
224
-
225
- return result;
326
+ systemIds: systemsByIncident.get(i.id) ?? [],
327
+ }));
328
+ });
226
329
  }
227
330
 
228
331
  /**
@@ -240,42 +343,34 @@ export class IncidentService {
240
343
  async listOpenIncidentsBySystem(): Promise<
241
344
  Record<string, IncidentWithSystems[]>
242
345
  > {
243
- const openRows = await this.db
244
- .select()
245
- .from(incidents)
246
- .where(ne(incidents.status, "resolved"));
247
- if (openRows.length === 0) return {};
248
-
249
- const openIds = openRows.map((i) => i.id);
250
- const systemRows = await this.db
251
- .select({
252
- incidentId: incidentSystems.incidentId,
253
- systemId: incidentSystems.systemId,
254
- })
255
- .from(incidentSystems)
256
- .where(inArray(incidentSystems.incidentId, openIds));
346
+ // Batch the open-incidents read + junction read behind one `SET LOCAL`.
347
+ return withScopedTransaction(this.db, async (tx) => {
348
+ const openRows = await tx
349
+ .select()
350
+ .from(incidents)
351
+ .where(ne(incidents.status, "resolved"));
352
+ if (openRows.length === 0) return {};
257
353
 
258
- const systemsByIncident = new Map<string, string[]>();
259
- for (const r of systemRows) {
260
- const list = systemsByIncident.get(r.incidentId);
261
- if (list) list.push(r.systemId);
262
- else systemsByIncident.set(r.incidentId, [r.systemId]);
263
- }
354
+ const systemsByIncident = await this.getSystemsByIncident(
355
+ tx,
356
+ openRows.map((i) => i.id),
357
+ );
264
358
 
265
- const result: Record<string, IncidentWithSystems[]> = {};
266
- for (const i of openRows) {
267
- const systemIds = systemsByIncident.get(i.id) ?? [];
268
- const incident: IncidentWithSystems = {
269
- ...i,
270
- description: i.description ?? undefined,
271
- systemIds,
272
- };
273
- for (const systemId of systemIds) {
274
- (result[systemId] ??= []).push(incident);
359
+ const result: Record<string, IncidentWithSystems[]> = {};
360
+ for (const i of openRows) {
361
+ const systemIds = systemsByIncident.get(i.id) ?? [];
362
+ const incident: IncidentWithSystems = {
363
+ ...i,
364
+ description: i.description ?? undefined,
365
+ systemIds,
366
+ };
367
+ for (const systemId of systemIds) {
368
+ (result[systemId] ??= []).push(incident);
369
+ }
275
370
  }
276
- }
277
371
 
278
- return result;
372
+ return result;
373
+ });
279
374
  }
280
375
 
281
376
  /**
@@ -440,7 +535,9 @@ export class IncidentService {
440
535
  // Atomic: the status flip and the timeline entry that records it must commit
441
536
  // together. Without the transaction a failed insert left the incident in a
442
537
  // new status with no update row explaining it (status/timeline divergence).
443
- await this.db.transaction(async (tx) => {
538
+ // The inserted row is returned via `.returning()`, removing the former
539
+ // post-commit re-select.
540
+ const update = await this.db.transaction(async (tx) => {
444
541
  // If status change is provided, update the incident status
445
542
  if (input.statusChange) {
446
543
  await tx
@@ -449,27 +546,200 @@ export class IncidentService {
449
546
  .where(eq(incidents.id, input.incidentId));
450
547
  }
451
548
 
452
- await tx.insert(incidentUpdates).values({
453
- id,
454
- incidentId: input.incidentId,
455
- message: input.message,
456
- statusChange: input.statusChange,
457
- createdBy: userId,
458
- });
549
+ const [inserted] = await tx
550
+ .insert(incidentUpdates)
551
+ .values({
552
+ id,
553
+ incidentId: input.incidentId,
554
+ message: input.message,
555
+ statusChange: input.statusChange,
556
+ visibility: input.visibility ?? "public",
557
+ createdBy: userId,
558
+ })
559
+ .returning();
560
+ return inserted;
459
561
  });
460
562
 
461
- const [update] = await this.db
563
+ return {
564
+ ...update,
565
+ statusChange: update.statusChange ?? undefined,
566
+ editedAt: update.editedAt ?? undefined,
567
+ editHistory: update.editHistory ?? [],
568
+ createdBy: update.createdBy ?? undefined,
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Edit a published update in place. Scoped by `incidentId` (mirrors
574
+ * `removeLink`): a caller authorized against THIS incident cannot pair an
575
+ * update id with a foreign incident. Only the provided fields change. When any
576
+ * field actually changes, the CURRENT values are archived into `editHistory`
577
+ * (oldest first) and `editedAt` is stamped, so the timeline can show a
578
+ * GitHub-style history of edits. A no-op edit (nothing changed) neither
579
+ * archives a snapshot nor marks the update "edited".
580
+ *
581
+ * When the edit touches `statusChange` OR re-times the update (`createdAt`),
582
+ * the incident's own `status` is re-derived from the most recent
583
+ * status-bearing update (see {@link deriveStatusFromTimeline}) so the header
584
+ * and timeline never diverge - including the edge case where the latest row is
585
+ * message-only and the header must follow an earlier status-bearing update,
586
+ * and the case where re-timing changes which update is latest.
587
+ */
588
+ async editUpdate(
589
+ input: EditIncidentUpdateInput,
590
+ ): Promise<IncidentUpdate | undefined> {
591
+ const [existing] = await this.db
462
592
  .select()
463
593
  .from(incidentUpdates)
464
- .where(eq(incidentUpdates.id, id));
594
+ .where(
595
+ and(
596
+ eq(incidentUpdates.id, input.id),
597
+ eq(incidentUpdates.incidentId, input.incidentId),
598
+ ),
599
+ );
600
+ if (!existing) return undefined;
601
+
602
+ // Only fields that actually differ count as a change; the edit form always
603
+ // re-sends message/status/visibility, so a bare save must not manufacture a
604
+ // spurious "edited" marker or history entry.
605
+ const messageChanged =
606
+ input.message !== undefined && input.message !== existing.message;
607
+ const statusChanged =
608
+ input.statusChange !== undefined &&
609
+ (input.statusChange ?? null) !== (existing.statusChange ?? null);
610
+ const visibilityChanged =
611
+ input.visibility !== undefined && input.visibility !== existing.visibility;
612
+ const createdAtChanged =
613
+ input.createdAt !== undefined &&
614
+ input.createdAt.getTime() !== existing.createdAt.getTime();
615
+ const contentChanged =
616
+ messageChanged || statusChanged || visibilityChanged || createdAtChanged;
617
+
618
+ const now = new Date();
619
+ const updateData: Partial<typeof incidentUpdates.$inferInsert> = {};
620
+ if (input.message !== undefined) updateData.message = input.message;
621
+ if (input.statusChange !== undefined)
622
+ updateData.statusChange = input.statusChange;
623
+ if (input.visibility !== undefined)
624
+ updateData.visibility = input.visibility;
625
+ if (input.createdAt !== undefined) updateData.createdAt = input.createdAt;
626
+
627
+ if (contentChanged) {
628
+ updateData.editedAt = now;
629
+ // Archive the pre-edit version (oldest first). Timestamps are ISO strings
630
+ // so the jsonb payload round-trips through JSON cleanly.
631
+ const snapshot: IncidentUpdateEditSnapshot = {
632
+ message: existing.message,
633
+ statusChange: existing.statusChange ?? undefined,
634
+ visibility: existing.visibility,
635
+ createdAt: existing.createdAt.toISOString(),
636
+ editedAt: now.toISOString(),
637
+ };
638
+ updateData.editHistory = [...(existing.editHistory ?? []), snapshot];
639
+ }
640
+
641
+ // Nothing provided to change: return the current row untouched (a bare
642
+ // `.set({})` would throw), leaving `editedAt`/`editHistory` as they were.
643
+ if (Object.keys(updateData).length === 0) {
644
+ return {
645
+ ...existing,
646
+ statusChange: existing.statusChange ?? undefined,
647
+ editedAt: existing.editedAt ?? undefined,
648
+ editHistory: existing.editHistory ?? [],
649
+ createdBy: existing.createdBy ?? undefined,
650
+ };
651
+ }
652
+
653
+ const update = await this.db.transaction(async (tx) => {
654
+ const [updated] = await tx
655
+ .update(incidentUpdates)
656
+ .set(updateData)
657
+ .where(
658
+ and(
659
+ eq(incidentUpdates.id, input.id),
660
+ eq(incidentUpdates.incidentId, input.incidentId),
661
+ ),
662
+ )
663
+ .returning();
664
+
665
+ // Re-derive the header from the most recent status-bearing update
666
+ // whenever the edit touched a status OR re-timed the update. This handles
667
+ // editing a non-latest update (header keeps the newest status), the
668
+ // message-only latest edge case (header follows the prior status-bearing
669
+ // update), AND a re-time that changes which update is latest.
670
+ if (input.statusChange !== undefined || createdAtChanged) {
671
+ const derived = await deriveStatusFromTimeline(tx, input.incidentId);
672
+ if (derived) {
673
+ await tx
674
+ .update(incidents)
675
+ .set({ status: derived, updatedAt: new Date() })
676
+ .where(eq(incidents.id, input.incidentId));
677
+ }
678
+ }
679
+ return updated;
680
+ });
465
681
 
466
682
  return {
467
683
  ...update,
468
684
  statusChange: update.statusChange ?? undefined,
685
+ editedAt: update.editedAt ?? undefined,
686
+ editHistory: update.editHistory ?? [],
469
687
  createdBy: update.createdBy ?? undefined,
470
688
  };
471
689
  }
472
690
 
691
+ /**
692
+ * Delete a published update. Scoped by `incidentId` (mirrors `removeLink`).
693
+ * Returns the parent incidentId so the caller can invalidate caches, or
694
+ * undefined if the update did not exist under that incident.
695
+ *
696
+ * When the deleted update CARRIED a status, the incident's `status` is
697
+ * re-derived from the remaining timeline in the SAME transaction (see
698
+ * {@link deriveStatusFromTimeline}) so deleting the latest status-bearing
699
+ * update never leaves the header pinned to a now-gone status. A message-only
700
+ * delete can never change the derived status, so it skips the re-derivation.
701
+ * If no status-bearing update remains, the status is left intact (deletion is
702
+ * irreversible; we never null a status the header still needs).
703
+ */
704
+ async deleteUpdate(
705
+ id: string,
706
+ incidentId: string,
707
+ ): Promise<string | undefined> {
708
+ return this.db.transaction(async (tx) => {
709
+ const [existing] = await tx
710
+ .select()
711
+ .from(incidentUpdates)
712
+ .where(
713
+ and(
714
+ eq(incidentUpdates.id, id),
715
+ eq(incidentUpdates.incidentId, incidentId),
716
+ ),
717
+ );
718
+ if (!existing) return;
719
+
720
+ await tx
721
+ .delete(incidentUpdates)
722
+ .where(
723
+ and(
724
+ eq(incidentUpdates.id, id),
725
+ eq(incidentUpdates.incidentId, incidentId),
726
+ ),
727
+ );
728
+
729
+ if (existing.statusChange !== null) {
730
+ const derived = await deriveStatusFromTimeline(tx, incidentId);
731
+ if (derived) {
732
+ await tx
733
+ .update(incidents)
734
+ .set({ status: derived, updatedAt: new Date() })
735
+ .where(eq(incidents.id, incidentId));
736
+ }
737
+ }
738
+
739
+ return existing.incidentId;
740
+ });
741
+ }
742
+
473
743
  /**
474
744
  * Resolve an incident
475
745
  */
@@ -536,16 +806,58 @@ export class IncidentService {
536
806
  */
537
807
  async addLink(input: AddIncidentLinkInput): Promise<IncidentLink> {
538
808
  const id = generateId();
539
- await this.db.insert(incidentLinks).values({
540
- id,
541
- incidentId: input.incidentId,
542
- label: input.label,
543
- url: input.url,
544
- });
809
+ // `.returning()` yields the inserted row directly, removing the former
810
+ // standalone re-select (one query instead of two).
545
811
  const [row] = await this.db
812
+ .insert(incidentLinks)
813
+ .values({
814
+ id,
815
+ incidentId: input.incidentId,
816
+ label: input.label,
817
+ url: input.url,
818
+ visibility: input.visibility ?? "public",
819
+ })
820
+ .returning();
821
+ return row;
822
+ }
823
+
824
+ /**
825
+ * Edit a hotlink in place. Scoped by `incidentId` (anti-spoof, mirrors
826
+ * `removeLink`): a link belonging to a different incident cannot be edited by
827
+ * pairing its id with an incident the caller manages. Only the provided fields
828
+ * change; returns the updated row, or undefined if the pair did not match.
829
+ */
830
+ async updateLink(
831
+ input: UpdateIncidentLinkInput,
832
+ ): Promise<IncidentLink | undefined> {
833
+ const [existing] = await this.db
546
834
  .select()
547
835
  .from(incidentLinks)
548
- .where(eq(incidentLinks.id, id));
836
+ .where(
837
+ and(
838
+ eq(incidentLinks.id, input.id),
839
+ eq(incidentLinks.incidentId, input.incidentId),
840
+ ),
841
+ );
842
+ if (!existing) return undefined;
843
+
844
+ const updateData: Partial<typeof incidentLinks.$inferInsert> = {};
845
+ if (input.label !== undefined) updateData.label = input.label;
846
+ if (input.url !== undefined) updateData.url = input.url;
847
+ if (input.visibility !== undefined)
848
+ updateData.visibility = input.visibility;
849
+ if (Object.keys(updateData).length === 0) return existing;
850
+
851
+ const [row] = await this.db
852
+ .update(incidentLinks)
853
+ .set(updateData)
854
+ .where(
855
+ and(
856
+ eq(incidentLinks.id, input.id),
857
+ eq(incidentLinks.incidentId, input.incidentId),
858
+ ),
859
+ )
860
+ .returning();
549
861
  return row;
550
862
  }
551
863