incidents.ts90 lines · main
1/**
2 * Status-page incident feed. The status page (server component) and the
3 * RSS route (server route handler) both call `fetchIncidents()` at
4 * request time and degrade to an empty list when the API is unreachable
5 * — the alternative is the status page itself going down while the
6 * operator is trying to publish an incident about the API being down.
7 *
8 * The source of truth is the `incidents` table on the api, written by
9 * admins via /dashboard/admin/incidents (step-up gated). Drizzle
10 * serializes `started_at`/`resolved_at` to ISO strings over JSON.
11 */
12
13const API_ORIGIN = process.env.BRIVEN_API_ORIGIN ?? 'https://api.briven.tech';
14const FETCH_TIMEOUT_MS = 3000;
15
16export interface IncidentEntry {
17 readonly id: string;
18 readonly startedAt: string;
19 readonly resolvedAt: string | null;
20 readonly severity: 'critical' | 'major' | 'minor' | 'maintenance';
21 readonly services: readonly string[];
22 readonly summary: string;
23 readonly postmortem: string;
24}
25
26interface FetchOpts {
27 activeOnly?: boolean;
28 limit?: number;
29 /** Skip the 30s server cache. The /status page passes true so the
30 * incident list is always fresh; the docs-shell banner leaves it
31 * false to avoid a fetch on every docs page render. */
32 fresh?: boolean;
33}
34
35export async function fetchIncidents(opts: FetchOpts = {}): Promise<readonly IncidentEntry[]> {
36 const params = new URLSearchParams();
37 if (opts.activeOnly) params.set('active', 'true');
38 if (opts.limit) params.set('limit', String(opts.limit));
39 const qs = params.toString();
40 const url = `${API_ORIGIN}/v1/status/incidents${qs ? `?${qs}` : ''}`;
41 try {
42 const res = await fetch(url, {
43 signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
44 headers: { accept: 'application/json' },
45 ...(opts.fresh
46 ? { cache: 'no-store' as const }
47 : { next: { revalidate: 30, tags: ['incidents'] } }),
48 });
49 if (!res.ok) return [];
50 const body = (await res.json()) as { incidents?: unknown };
51 if (!Array.isArray(body.incidents)) return [];
52 return body.incidents.map(normalize).filter((x): x is IncidentEntry => x !== null);
53 } catch {
54 return [];
55 }
56}
57
58export function isOngoing(inc: IncidentEntry): boolean {
59 return inc.resolvedAt === null;
60}
61
62function normalize(raw: unknown): IncidentEntry | null {
63 if (!raw || typeof raw !== 'object') return null;
64 const r = raw as Record<string, unknown>;
65 const id = typeof r.id === 'string' ? r.id : null;
66 const startedAt = toIso(r.startedAt);
67 const severity = r.severity;
68 const services = Array.isArray(r.services) ? r.services.filter((s) => typeof s === 'string') : null;
69 const summary = typeof r.summary === 'string' ? r.summary : null;
70 if (!id || !startedAt || !services || !summary) return null;
71 if (severity !== 'critical' && severity !== 'major' && severity !== 'minor' && severity !== 'maintenance') {
72 return null;
73 }
74 return {
75 id,
76 startedAt,
77 resolvedAt: toIso(r.resolvedAt),
78 severity,
79 services: services as string[],
80 summary,
81 postmortem: typeof r.postmortem === 'string' ? r.postmortem : '',
82 };
83}
84
85function toIso(v: unknown): string | null {
86 if (typeof v !== 'string') return null;
87 const d = new Date(v);
88 if (Number.isNaN(d.getTime())) return null;
89 return d.toISOString();
90}