@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/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
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
|
503
|
+
await notifyIncidentChanged({
|
|
429
504
|
incidentId: input.incidentId,
|
|
430
505
|
systemIds: incident.systemIds,
|
|
431
506
|
action: "updated",
|
|
432
507
|
});
|
|
433
508
|
|
|
434
|
-
//
|
|
435
|
-
|
|
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
|
|
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
|
|
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) => ({
|