incidents.ts172 lines · main
1import { newId, NotFoundError, ValidationError } from '@briven/shared';
2import { desc, eq, isNull } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import {
6 incidentSeverity,
7 incidents,
8 type Incident,
9 type IncidentSeverity,
10} from '../db/schema.js';
11
12/**
13 * Operator-published platform incidents. Admin writes (create/update/
14 * resolve) go through the admin router + step-up gate; public reads
15 * power the /status page + RSS feed (separate consumer turn).
16 */
17
18// Service vocabulary kept in sync with what apps/realtime + status
19// probes report. Adding a new service: append here, no migration needed.
20const KNOWN_SERVICES = new Set(['api', 'realtime', 'runtime', 'web', 'docs', 'all']);
21
22function validateServices(services: readonly string[]): void {
23 if (services.length === 0) {
24 throw new ValidationError('at least one affected service is required');
25 }
26 for (const s of services) {
27 if (!KNOWN_SERVICES.has(s)) {
28 throw new ValidationError(
29 `unknown service "${s}". known: ${Array.from(KNOWN_SERVICES).join(', ')}`,
30 );
31 }
32 }
33}
34
35function validateSeverity(severity: string): asserts severity is IncidentSeverity {
36 if (!(incidentSeverity as readonly string[]).includes(severity)) {
37 throw new ValidationError(
38 `severity must be one of: ${incidentSeverity.join(', ')}`,
39 );
40 }
41}
42
43export async function listIncidents(opts: { limit?: number; activeOnly?: boolean } = {}): Promise<
44 Incident[]
45> {
46 const db = getDb();
47 if (opts.activeOnly) {
48 return db
49 .select()
50 .from(incidents)
51 .where(isNull(incidents.resolvedAt))
52 .orderBy(desc(incidents.startedAt))
53 .limit(opts.limit ?? 50);
54 }
55 return db
56 .select()
57 .from(incidents)
58 .orderBy(desc(incidents.startedAt))
59 .limit(opts.limit ?? 50);
60}
61
62export async function getIncident(id: string): Promise<Incident> {
63 const db = getDb();
64 const rows = await db.select().from(incidents).where(eq(incidents.id, id)).limit(1);
65 const row = rows[0];
66 if (!row) throw new NotFoundError('incident', id);
67 return row;
68}
69
70export interface CreateIncidentInput {
71 startedAt?: Date;
72 severity: string;
73 services: readonly string[];
74 summary: string;
75 postmortem?: string;
76 createdBy: string | null;
77}
78
79export async function createIncident(input: CreateIncidentInput): Promise<Incident> {
80 validateSeverity(input.severity);
81 validateServices(input.services);
82 const summary = input.summary.trim();
83 if (summary.length === 0 || summary.length > 2000) {
84 throw new ValidationError('summary must be 1-2000 chars');
85 }
86 const postmortem = (input.postmortem ?? '').trim();
87 if (postmortem.length > 20_000) {
88 throw new ValidationError('postmortem cannot exceed 20kB');
89 }
90 const db = getDb();
91 const inserted = await db
92 .insert(incidents)
93 .values({
94 id: `inc_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_${newId('ev').slice(-6)}`,
95 startedAt: input.startedAt ?? new Date(),
96 severity: input.severity,
97 services: input.services,
98 summary,
99 postmortem,
100 createdBy: input.createdBy,
101 })
102 .returning();
103 const row = inserted[0];
104 if (!row) throw new Error('insert returned no row');
105 return row;
106}
107
108export interface UpdateIncidentInput {
109 summary?: string;
110 postmortem?: string;
111 severity?: string;
112 services?: readonly string[];
113}
114
115export async function updateIncident(
116 id: string,
117 patch: UpdateIncidentInput,
118): Promise<Incident> {
119 // Load + validate first so we 404 fast on missing rows.
120 await getIncident(id);
121 const updates: Partial<Incident> = { updatedAt: new Date() };
122 if (patch.summary !== undefined) {
123 const s = patch.summary.trim();
124 if (s.length === 0 || s.length > 2000) {
125 throw new ValidationError('summary must be 1-2000 chars');
126 }
127 updates.summary = s;
128 }
129 if (patch.postmortem !== undefined) {
130 const p = patch.postmortem.trim();
131 if (p.length > 20_000) {
132 throw new ValidationError('postmortem cannot exceed 20kB');
133 }
134 updates.postmortem = p;
135 }
136 if (patch.severity !== undefined) {
137 validateSeverity(patch.severity);
138 updates.severity = patch.severity;
139 }
140 if (patch.services !== undefined) {
141 validateServices(patch.services);
142 updates.services = patch.services;
143 }
144
145 const db = getDb();
146 const result = await db
147 .update(incidents)
148 .set(updates)
149 .where(eq(incidents.id, id))
150 .returning();
151 if (!result[0]) throw new NotFoundError('incident', id);
152 return result[0];
153}
154
155/**
156 * Mark an incident resolved. Idempotent — calling resolve on an
157 * already-resolved incident is a no-op (preserves the original
158 * resolvedAt). Set `resolvedAt: null` via `updateIncident` if you need
159 * to re-open one.
160 */
161export async function resolveIncident(id: string): Promise<Incident> {
162 const existing = await getIncident(id);
163 if (existing.resolvedAt) return existing;
164 const db = getDb();
165 const result = await db
166 .update(incidents)
167 .set({ resolvedAt: new Date(), updatedAt: new Date() })
168 .where(eq(incidents.id, id))
169 .returning();
170 if (!result[0]) throw new NotFoundError('incident', id);
171 return result[0];
172}