@checkstack/incident-backend 1.10.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/CHANGELOG.md +416 -0
- package/drizzle/0005_real_leper_queen.sql +4 -0
- package/drizzle/0006_last_princess_powerful.sql +1 -0
- package/drizzle/meta/0005_snapshot.json +346 -0
- package/drizzle/meta/0006_snapshot.json +353 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +24 -22
- package/src/ai/incident-add-link.test.ts +1 -0
- package/src/ai/incident-add-update.test.ts +6 -1
- package/src/ai/incident-delete-update.test.ts +59 -0
- package/src/ai/incident-delete-update.ts +73 -0
- package/src/ai/incident-remove-link.test.ts +5 -5
- package/src/ai/incident-remove-link.ts +9 -5
- package/src/ai/register-ai-tools.ts +2 -0
- package/src/automations.test.ts +70 -0
- package/src/automations.ts +77 -4
- package/src/hooks.ts +54 -4
- package/src/index.ts +4 -0
- package/src/notifications.test.ts +181 -0
- package/src/notifications.ts +12 -1
- package/src/read-visibility.test.ts +158 -0
- package/src/read-visibility.ts +99 -0
- package/src/router.test.ts +69 -1
- package/src/router.ts +196 -16
- package/src/schema.ts +27 -0
- package/src/service-reads.it.test.ts +353 -0
- package/src/service-updates.it.test.ts +340 -0
- package/src/service.it.test.ts +187 -0
- package/src/service.test.ts +235 -1
- package/src/service.ts +501 -180
- package/src/status-page-widget.test.ts +149 -0
- package/src/status-page-widget.ts +133 -37
package/src/service.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { eq, and, inArray, ne, isNotNull } from "drizzle-orm";
|
|
2
|
-
import
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
120
|
+
return incidentRows.map((i) => ({
|
|
85
121
|
...i,
|
|
86
122
|
description: i.description ?? undefined,
|
|
87
|
-
systemIds:
|
|
88
|
-
});
|
|
89
|
-
}
|
|
123
|
+
systemIds: systemsByIncident.get(i.id) ?? [],
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
90
127
|
|
|
91
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
170
|
+
if (!incident) return;
|
|
104
171
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
172
|
+
const systems = await tx
|
|
173
|
+
.select({ systemId: incidentSystems.systemId })
|
|
174
|
+
.from(incidentSystems)
|
|
175
|
+
.where(eq(incidentSystems.incidentId, id));
|
|
109
176
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
177
|
+
const updates = await tx
|
|
178
|
+
.select()
|
|
179
|
+
.from(incidentUpdates)
|
|
180
|
+
.where(eq(incidentUpdates.incidentId, id));
|
|
114
181
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
182
|
+
const links = await tx
|
|
183
|
+
.select()
|
|
184
|
+
.from(incidentLinks)
|
|
185
|
+
.where(eq(incidentLinks.incidentId, id));
|
|
119
186
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
304
|
+
const ids = systemIncidents.map((r) => r.incidentId);
|
|
305
|
+
if (ids.length === 0) return [];
|
|
203
306
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
.
|
|
215
|
-
|
|
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
|
-
|
|
323
|
+
return rows.map((i) => ({
|
|
219
324
|
...i,
|
|
220
325
|
description: i.description ?? undefined,
|
|
221
|
-
systemIds:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
540
|
-
|
|
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(
|
|
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
|
|
|
@@ -554,13 +866,22 @@ export class IncidentService {
|
|
|
554
866
|
* invalidate the right cache entry, or undefined if the link did not
|
|
555
867
|
* exist.
|
|
556
868
|
*/
|
|
557
|
-
async removeLink(id: string): Promise<string | undefined> {
|
|
869
|
+
async removeLink(id: string, incidentId: string): Promise<string | undefined> {
|
|
870
|
+
// Scope by incidentId: the caller is authorized (idParam) against THIS
|
|
871
|
+
// incident, so a link belonging to a different incident must not be
|
|
872
|
+
// removable by pairing its link id with an incident the caller manages.
|
|
558
873
|
const [existing] = await this.db
|
|
559
874
|
.select()
|
|
560
875
|
.from(incidentLinks)
|
|
561
|
-
.where(
|
|
876
|
+
.where(
|
|
877
|
+
and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
|
|
878
|
+
);
|
|
562
879
|
if (!existing) return undefined;
|
|
563
|
-
await this.db
|
|
880
|
+
await this.db
|
|
881
|
+
.delete(incidentLinks)
|
|
882
|
+
.where(
|
|
883
|
+
and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
|
|
884
|
+
);
|
|
564
885
|
return existing.incidentId;
|
|
565
886
|
}
|
|
566
887
|
|