@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/src/router.ts CHANGED
@@ -7,9 +7,12 @@ import {
7
7
  autoAuthMiddleware,
8
8
  correlationMiddleware,
9
9
  Logger,
10
+ type EventBus,
10
11
  type RpcContext,
11
12
  } from "@checkstack/backend-api";
12
13
  import type { SignalService } from "@checkstack/signal-common";
14
+ import type { IncidentLifecycleChangedPayload } from "@checkstack/incident-common";
15
+ import { emitIncidentLifecycleChanged } from "./hooks";
13
16
  import type { IncidentService } from "./service";
14
17
  import { CatalogApi } from "@checkstack/catalog-common";
15
18
  import { AuthApi } from "@checkstack/auth-common";
@@ -28,10 +31,22 @@ import {
28
31
  toIncidentEntityState,
29
32
  type IncidentEntityState,
30
33
  } from "./incident-entity";
34
+ import {
35
+ resolveIncidentAudience,
36
+ filterByAudience,
37
+ scopeEditHistory,
38
+ } from "./read-visibility";
31
39
 
32
40
  export interface IncidentRouterDeps {
33
41
  service: IncidentService;
34
42
  signalService: SignalService;
43
+ /**
44
+ * Distributed event bus used to emit the `incident.lifecycle.changed` hook on
45
+ * every lifecycle mutation (alongside the realtime `INCIDENT_UPDATED` signal),
46
+ * so backend consumers (e.g. SLO downtime reconciliation) react with
47
+ * exactly-once `work-queue` delivery. Optional so tests can omit it.
48
+ */
49
+ eventBus?: EventBus;
35
50
  catalogClient: InferClient<typeof CatalogApi>;
36
51
  notificationClient: InferClient<
37
52
  typeof import("@checkstack/notification-common").NotificationApi
@@ -46,6 +61,7 @@ export interface IncidentRouterDeps {
46
61
  export function createRouter({
47
62
  service,
48
63
  signalService,
64
+ eventBus,
49
65
  catalogClient,
50
66
  notificationClient,
51
67
  authClient,
@@ -53,6 +69,21 @@ export function createRouter({
53
69
  cache,
54
70
  getIncidentEntity,
55
71
  }: IncidentRouterDeps) {
72
+ /**
73
+ * Announce an incident lifecycle change: broadcast the realtime
74
+ * `INCIDENT_UPDATED` signal (frontend refetch) AND emit the distributed
75
+ * `incident.lifecycle.changed` hook (backend consumers, e.g. SLO downtime).
76
+ * Kept as one call so the signal and the hook can never drift apart. Both are
77
+ * best-effort: the write is already committed, so a delivery failure must
78
+ * never surface as a client error (the signal is inherently non-throwing; the
79
+ * hook emit is guarded here).
80
+ */
81
+ const notifyIncidentChanged = async (
82
+ payload: IncidentLifecycleChangedPayload,
83
+ ) => {
84
+ await signalService.broadcast(INCIDENT_UPDATED, payload);
85
+ await emitIncidentLifecycleChanged({ eventBus, logger, payload });
86
+ };
56
87
  /**
57
88
  * Resolve user IDs to profile names for a list of updates.
58
89
  * Falls back to "Unknown User" if the user cannot be found.
@@ -150,7 +181,7 @@ export function createRouter({
150
181
  incidentId: resolved.id,
151
182
  systemIds: resolved.systemIds,
152
183
  });
153
- await signalService.broadcast(INCIDENT_UPDATED, {
184
+ await notifyIncidentChanged({
154
185
  incidentId: resolved.id,
155
186
  systemIds: resolved.systemIds,
156
187
  action: "resolved",
@@ -166,6 +197,7 @@ export function createRouter({
166
197
  systemNames,
167
198
  action: "resolved",
168
199
  severity: resolved.severity,
200
+ updateMessage: message,
169
201
  });
170
202
  return { status: "resolved", incident: resolved };
171
203
  }
@@ -195,7 +227,7 @@ export function createRouter({
195
227
  incidentId: id,
196
228
  systemIds: incident.systemIds,
197
229
  });
198
- await signalService.broadcast(INCIDENT_UPDATED, {
230
+ await notifyIncidentChanged({
199
231
  incidentId: id,
200
232
  systemIds: incident.systemIds,
201
233
  action: "deleted",
@@ -217,19 +249,35 @@ export function createRouter({
217
249
  };
218
250
  }),
219
251
 
220
- getIncident: os.getIncident.handler(async ({ input }) => {
252
+ getIncident: os.getIncident.handler(async ({ input, context }) => {
221
253
  const result = await cache.wrapIncident(input.id, () =>
222
254
  service.getIncident(input.id),
223
255
  );
224
256
  if (!result) {
225
-
257
+
226
258
  return null;
227
259
  }
260
+ // Item 3/5: filter updates + hotlinks by the caller's audience SERVER-SIDE
261
+ // (never CSS). The cache stores the full record; visibility is applied
262
+ // per-request so an internal note or logged-in-only link never ships to an
263
+ // anonymous or non-manager caller.
264
+ const audience = await resolveIncidentAudience({
265
+ context: { user: context.user, auth: context.auth },
266
+ incidentId: input.id,
267
+ });
228
268
  // User-name resolution stays outside the cache: it's a foreign-system
229
269
  // lookup with its own freshness needs and is cheap relative to the
230
270
  // incident query.
231
- const updatesWithNames = await resolveUserNames(result.updates);
232
- return { ...result, updates: updatesWithNames };
271
+ // Filter by current visibility, THEN strip the manager-only edit history
272
+ // (a prior version may have been internal before being made public).
273
+ const updatesWithNames = await resolveUserNames(
274
+ scopeEditHistory(filterByAudience(result.updates, audience), audience),
275
+ );
276
+ return {
277
+ ...result,
278
+ updates: updatesWithNames,
279
+ links: filterByAudience(result.links, audience),
280
+ };
233
281
  }),
234
282
 
235
283
  getIncidentsForSystem: os.getIncidentsForSystem.handler(
@@ -260,6 +308,32 @@ export function createRouter({
260
308
  },
261
309
  ),
262
310
 
311
+ getBulkIncidentUpdates: os.getBulkIncidentUpdates.handler(
312
+ async ({ input, context }) => {
313
+ const raw = await service.getBulkIncidentUpdates(input.incidentIds);
314
+ // Mirror getIncident's per-incident audience filter (Item 3/5) so this
315
+ // bulk endpoint can never leak logged-in/internal updates - or author
316
+ // identity - to a caller who is not a manager of that incident. The
317
+ // public status-page widget re-filters to `public` on top; a trusted
318
+ // service call resolves as `manager` and sees all, exactly as
319
+ // getIncident does. Names are resolved per incident (batched within),
320
+ // matching the single-incident read.
321
+ const updates: Record<string, IncidentUpdate[]> = {};
322
+ await Promise.all(
323
+ Object.entries(raw).map(async ([incidentId, list]) => {
324
+ const audience = await resolveIncidentAudience({
325
+ context: { user: context.user, auth: context.auth },
326
+ incidentId,
327
+ });
328
+ updates[incidentId] = await resolveUserNames(
329
+ scopeEditHistory(filterByAudience(list, audience), audience),
330
+ );
331
+ }),
332
+ );
333
+ return { updates };
334
+ },
335
+ ),
336
+
263
337
  getActiveHealthOverrides: os.getActiveHealthOverrides.handler(
264
338
  async ({ input }) => {
265
339
  // Deliberately un-cached: this feeds live system-health derivation, so
@@ -307,7 +381,7 @@ export function createRouter({
307
381
  });
308
382
 
309
383
  // Broadcast signal for realtime updates
310
- await signalService.broadcast(INCIDENT_UPDATED, {
384
+ await notifyIncidentChanged({
311
385
  incidentId: result.id,
312
386
  systemIds: result.systemIds,
313
387
  action: "created",
@@ -325,6 +399,7 @@ export function createRouter({
325
399
  systemNames,
326
400
  action: "created",
327
401
  severity: result.severity,
402
+ updateMessage: input.initialMessage,
328
403
  });
329
404
 
330
405
  return result;
@@ -362,7 +437,7 @@ export function createRouter({
362
437
  });
363
438
 
364
439
  // Broadcast signal for realtime updates
365
- await signalService.broadcast(INCIDENT_UPDATED, {
440
+ await notifyIncidentChanged({
366
441
  incidentId: result.id,
367
442
  systemIds: result.systemIds,
368
443
  action: "updated",
@@ -425,19 +500,27 @@ export function createRouter({
425
500
  systemIds: incident.systemIds,
426
501
  });
427
502
 
428
- await signalService.broadcast(INCIDENT_UPDATED, {
503
+ await notifyIncidentChanged({
429
504
  incidentId: input.incidentId,
430
505
  systemIds: incident.systemIds,
431
506
  action: "updated",
432
507
  });
433
508
 
434
- // Send notifications when status changes
435
- if (input.statusChange && previousStatus !== input.statusChange) {
509
+ // Notify subscribers on every update EXCEPT an internal-only operator
510
+ // note: an internal update (Item 3/5) must NEVER reach system
511
+ // subscribers. A status change picks the matching verb
512
+ // (resolved / reopened); a message-only update (no status change) still
513
+ // reaches subscribers as an "updated" notification so the latest update
514
+ // text is delivered rather than silently swallowed.
515
+ if (input.visibility !== "internal") {
516
+ const isStatusChange =
517
+ !!input.statusChange && previousStatus !== input.statusChange;
518
+
436
519
  // Determine notification action based on status transition
437
520
  let notificationAction: "resolved" | "reopened" | "updated";
438
- if (input.statusChange === "resolved") {
521
+ if (isStatusChange && input.statusChange === "resolved") {
439
522
  notificationAction = "resolved";
440
- } else if (previousStatus === "resolved") {
523
+ } else if (isStatusChange && previousStatus === "resolved") {
441
524
  // Reopening: was resolved, now not resolved
442
525
  notificationAction = "reopened";
443
526
  } else {
@@ -455,6 +538,7 @@ export function createRouter({
455
538
  systemNames,
456
539
  action: notificationAction,
457
540
  severity: incident.severity,
541
+ updateMessage: input.message,
458
542
  });
459
543
  }
460
544
  }
@@ -462,6 +546,85 @@ export function createRouter({
462
546
  return result;
463
547
  }),
464
548
 
549
+ editUpdate: os.editUpdate.handler(async ({ input }) => {
550
+ // Drive the edit through the reactive `incident` entity: a `statusChange`
551
+ // edit on the LATEST update re-derives the incident status (service
552
+ // layer), so `apply` re-reads and returns the post-write reactive state
553
+ // and the deriver fires the right change event. Editing an update never
554
+ // re-notifies subscribers (only status transitions via addUpdate do).
555
+ let updateResult: IncidentUpdate | undefined;
556
+ let incident: Awaited<ReturnType<typeof service.getIncident>>;
557
+ await writeIncidentEntity({
558
+ handle: getIncidentEntity?.(),
559
+ incidentId: input.incidentId,
560
+ apply: async () => {
561
+ updateResult = await service.editUpdate(input);
562
+ if (!updateResult) {
563
+ throw new ORPCError("NOT_FOUND", { message: "Update not found" });
564
+ }
565
+ incident = await service.getIncident(input.incidentId);
566
+ if (!incident) {
567
+ throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
568
+ }
569
+ return toIncidentEntityState(incident);
570
+ },
571
+ });
572
+ if (!updateResult || !incident) {
573
+ throw new ORPCError("NOT_FOUND", { message: "Update not found" });
574
+ }
575
+
576
+ await cache.invalidateForMutation({
577
+ incidentId: input.incidentId,
578
+ systemIds: incident.systemIds,
579
+ });
580
+ await notifyIncidentChanged({
581
+ incidentId: input.incidentId,
582
+ systemIds: incident.systemIds,
583
+ action: "updated",
584
+ });
585
+ return updateResult;
586
+ }),
587
+
588
+ deleteUpdate: os.deleteUpdate.handler(async ({ input }) => {
589
+ // Probe first: a no-op delete (missing incident/update) must NOT drive an
590
+ // entity write. `getIncident` returns the FULL (unfiltered) timeline, so
591
+ // the presence check is visibility-independent.
592
+ const before = await service.getIncident(input.incidentId);
593
+ if (!before || !before.updates.some((u) => u.id === input.id)) {
594
+ return { success: false };
595
+ }
596
+
597
+ // Drive the delete through the reactive `incident` entity: deleting the
598
+ // latest status-bearing update re-derives the incident status (service
599
+ // layer), so `apply` re-reads and returns the post-write reactive state
600
+ // and the deriver fires the right change event (symmetric with editUpdate).
601
+ let incident: Awaited<ReturnType<typeof service.getIncident>>;
602
+ await writeIncidentEntity({
603
+ handle: getIncidentEntity?.(),
604
+ incidentId: input.incidentId,
605
+ apply: async () => {
606
+ await service.deleteUpdate(input.id, input.incidentId);
607
+ incident = await service.getIncident(input.incidentId);
608
+ if (!incident) {
609
+ throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
610
+ }
611
+ return toIncidentEntityState(incident);
612
+ },
613
+ });
614
+ if (incident) {
615
+ await cache.invalidateForMutation({
616
+ incidentId: input.incidentId,
617
+ systemIds: incident.systemIds,
618
+ });
619
+ await notifyIncidentChanged({
620
+ incidentId: input.incidentId,
621
+ systemIds: incident.systemIds,
622
+ action: "updated",
623
+ });
624
+ }
625
+ return { success: true };
626
+ }),
627
+
465
628
  resolveIncident: os.resolveIncident.handler(async ({ input, context }) => {
466
629
  const userId =
467
630
  context.user && "id" in context.user ? context.user.id : undefined;
@@ -580,7 +743,7 @@ export function createRouter({
580
743
  systemIds: result.systemIds,
581
744
  });
582
745
 
583
- await signalService.broadcast(INCIDENT_UPDATED, {
746
+ await notifyIncidentChanged({
584
747
  incidentId: result.id,
585
748
  systemIds: result.systemIds,
586
749
  action: "created",
@@ -597,6 +760,7 @@ export function createRouter({
597
760
  systemNames,
598
761
  action: "created",
599
762
  severity: result.severity,
763
+ updateMessage: input.initialMessage,
600
764
  });
601
765
 
602
766
  return { id: result.id };
@@ -638,7 +802,7 @@ export function createRouter({
638
802
  systemIds: result.systemIds,
639
803
  });
640
804
 
641
- await signalService.broadcast(INCIDENT_UPDATED, {
805
+ await notifyIncidentChanged({
642
806
  incidentId: result.id,
643
807
  systemIds: result.systemIds,
644
808
  action: "resolved",
@@ -655,6 +819,7 @@ export function createRouter({
655
819
  systemNames,
656
820
  action: "resolved",
657
821
  severity: result.severity,
822
+ updateMessage: input.message,
658
823
  });
659
824
 
660
825
  return { success: true };
@@ -675,8 +840,23 @@ export function createRouter({
675
840
  return link;
676
841
  }),
677
842
 
843
+ updateLink: os.updateLink.handler(async ({ input }) => {
844
+ const link = await service.updateLink(input);
845
+ if (!link) {
846
+ throw new ORPCError("NOT_FOUND", { message: "Link not found" });
847
+ }
848
+ const incident = await service.getIncident(input.incidentId);
849
+ if (incident) {
850
+ await cache.invalidateForMutation({
851
+ incidentId: incident.id,
852
+ systemIds: incident.systemIds,
853
+ });
854
+ }
855
+ return link;
856
+ }),
857
+
678
858
  removeLink: os.removeLink.handler(async ({ input }) => {
679
- const incidentId = await service.removeLink(input.id);
859
+ const incidentId = await service.removeLink(input.id, input.incidentId);
680
860
  if (!incidentId) {
681
861
  return { success: false };
682
862
  }
package/src/schema.ts CHANGED
@@ -6,7 +6,9 @@ import {
6
6
  primaryKey,
7
7
  boolean,
8
8
  uniqueIndex,
9
+ jsonb,
9
10
  } from "drizzle-orm/pg-core";
11
+ import type { IncidentUpdateEditSnapshot } from "@checkstack/incident-common";
10
12
 
11
13
  /**
12
14
  * Incident status enum
@@ -39,6 +41,17 @@ export const incidentHealthOverrideEnum = pgEnum("incident_health_override", [
39
41
  "unhealthy",
40
42
  ]);
41
43
 
44
+ /**
45
+ * Audience for an incident update or hotlink. Filtered server-side on the read
46
+ * path: anonymous / public-status-page reads see only `public`; authenticated
47
+ * non-managers additionally see `logged_in`; managers see everything.
48
+ */
49
+ export const incidentVisibilityEnum = pgEnum("incident_content_visibility", [
50
+ "public",
51
+ "logged_in",
52
+ "internal",
53
+ ]);
54
+
42
55
  /**
43
56
  * Main incidents table
44
57
  */
@@ -82,7 +95,18 @@ export const incidentUpdates = pgTable("incident_updates", {
82
95
  .references(() => incidents.id, { onDelete: "cascade" }),
83
96
  message: text("message").notNull(),
84
97
  statusChange: incidentStatusEnum("status_change"),
98
+ visibility: incidentVisibilityEnum("visibility").notNull().default("public"),
85
99
  createdAt: timestamp("created_at").defaultNow().notNull(),
100
+ // Set when the update is edited in place (null = never edited).
101
+ editedAt: timestamp("edited_at"),
102
+ // Prior versions archived on each in-place edit (oldest first). Durable,
103
+ // globally-readable history of edits (jsonb, defaults to an empty array so
104
+ // existing rows backfill cleanly). Manager-facing; the read path strips it
105
+ // for non-manager audiences (see read-visibility).
106
+ editHistory: jsonb("edit_history")
107
+ .$type<IncidentUpdateEditSnapshot[]>()
108
+ .notNull()
109
+ .default([]),
86
110
  createdBy: text("created_by"),
87
111
  });
88
112
 
@@ -99,6 +123,9 @@ export const incidentLinks = pgTable(
99
123
  .references(() => incidents.id, { onDelete: "cascade" }),
100
124
  label: text("label"),
101
125
  url: text("url").notNull(),
126
+ visibility: incidentVisibilityEnum("visibility")
127
+ .notNull()
128
+ .default("public"),
102
129
  createdAt: timestamp("created_at").defaultNow().notNull(),
103
130
  },
104
131
  (t) => ({