incident-status.ts146 lines · main
1import { IS_PLATFORM } from 'common'
2import z from 'zod'
3
4import { InternalServerError } from '@/lib/api/apiHelpers'
5
6export type IncidentCache = {
7 affected_regions: Array<string> | null
8 affects_project_creation: boolean
9 /** When true, the banner is shown unconditionally regardless of regions or project state. */
10 force?: boolean
11}
12
13export type IncidentMetadata = {
14 dashboard_metadata?: {
15 show_banner?: boolean
16 }
17}
18
19export type IncidentInfo = {
20 id: string
21 name: string
22 status: string
23 impact: string
24 active_since: string
25 metadata: IncidentMetadata
26 cache?: IncidentCache | null
27}
28
29const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
30const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID
31const STATUSPAGE_API_KEY = process.env.STATUSPAGE_API_KEY
32
33function getIncidentsEndpoint(): string {
34 return `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved`
35}
36
37const StatusPageIncidentsSchema = z.array(
38 z.object({
39 id: z.string(),
40 name: z.string(),
41 status: z.string(),
42 created_at: z.string(),
43 scheduled_for: z.string().nullable(),
44 impact: z.string(),
45 metadata: z
46 .object({
47 dashboard_metadata: z
48 .object({
49 show_banner: z.boolean().optional(),
50 })
51 .optional(),
52 })
53 .optional()
54 .default({}),
55 })
56)
57
58/**
59 * Fetches active incidents from the StatusPage API.
60 * This function is used both by the API route and the AI assistant.
61 *
62 * @returns Array of active incidents
63 * @throws InternalServerError if StatusPage is not configured or returns an error
64 */
65export async function getActiveIncidents(): Promise<IncidentInfo[]> {
66 if (!IS_PLATFORM) {
67 return []
68 }
69
70 if (!STATUSPAGE_PAGE_ID) {
71 throw new InternalServerError('StatusPage page ID is not configured')
72 }
73
74 if (!STATUSPAGE_API_KEY) {
75 throw new InternalServerError('StatusPage API key is not configured')
76 }
77
78 const response = await fetch(getIncidentsEndpoint(), {
79 headers: {
80 Authorization: `OAuth ${STATUSPAGE_API_KEY}`,
81 Accept: 'application/json',
82 'Content-Type': 'application/json',
83 },
84 next: { revalidate: 180 },
85 signal: AbortSignal.timeout(30_000),
86 })
87 const responseText = await response.text()
88
89 if (!response.ok) {
90 const retryAfter = response.headers.get('Retry-After') ?? undefined
91 throw new InternalServerError(`StatusPage API responded with ${response.status}`, {
92 status: response.status,
93 body: responseText,
94 ...(retryAfter !== undefined && { retryAfter }),
95 })
96 }
97
98 let incidentsJson: unknown
99 try {
100 incidentsJson = JSON.parse(responseText)
101 } catch (error) {
102 throw new InternalServerError('StatusPage API response could not be parsed as JSON', {
103 error: error instanceof Error ? error.message : error,
104 body: responseText,
105 })
106 }
107
108 const result = StatusPageIncidentsSchema.safeParse(incidentsJson)
109
110 if (!result.success) {
111 throw new InternalServerError('StatusPage API response did not match expected schema', {
112 issues: result.error.issues,
113 })
114 }
115
116 const now = Date.now()
117 const activeIncidents = result.data.filter((incident) => {
118 const hasNoScheduledTime = !incident.scheduled_for
119 if (hasNoScheduledTime) {
120 return true
121 }
122
123 const scheduledTime = Date.parse(incident.scheduled_for!)
124 const isScheduledTimeInvalid = Number.isNaN(scheduledTime)
125 if (isScheduledTimeInvalid) {
126 // Keep the record but note it locally for debugging
127 console.warn('Encountered incident with invalid scheduled_for date', {
128 incidentId: incident.id,
129 scheduled_for: incident.scheduled_for,
130 })
131 return true
132 }
133
134 const hasScheduledTimePassed = scheduledTime <= now
135 return hasScheduledTimePassed
136 })
137
138 return activeIncidents.map((incident) => ({
139 id: incident.id,
140 name: incident.name,
141 status: incident.status,
142 impact: incident.impact,
143 active_since: incident.scheduled_for ?? incident.created_at,
144 metadata: incident.metadata,
145 }))
146}