incident-banner.ts146 lines · main
1import { createHash } from 'crypto'
2import { IS_PROD } from 'common'
3import z from 'zod'
4
5import { InternalServerError } from '@/lib/api/apiHelpers'
6
7const INCIDENT_IO_BASE_URL = 'https://api.incident.io/v2'
8
9const BANNER_FIELD_ID = '01KKCFNW31EGRMD3JQ58E2TJ2M'
10const METADATA_FIELD_ID = '01KKCD4KNWQ7HYSXT72CHB7WR4'
11
12const MINOR_SEVERITY_ID = '01J7BTA8DEF371JQSXGBZYZY7D'
13
14const SENTINEL_VALUE_SHOW_BANNER = '1'
15const SENTINEL_VALUE_FORCE_BANNER = '100'
16
17const FALLBACK_METADATA = { affected_regions: null, affects_project_creation: false }
18
19const MetadataSchema = z.object({
20 affected_regions: z.union([z.array(z.string()), z.null()]),
21 affects_project_creation: z.boolean(),
22})
23
24interface CustomFieldValue {
25 value_option?: { id: string; value: string }
26 value_text?: string
27 value_numeric?: string
28}
29
30interface CustomFieldEntry {
31 custom_field: { id: string }
32 values: Array<CustomFieldValue>
33}
34
35interface Incident {
36 id: string
37 name: string
38 mode: string
39 created_at: string
40 custom_field_entries: Array<CustomFieldEntry>
41}
42
43interface IncidentIoListResponse {
44 incidents: Array<Incident>
45 pagination_meta?: { after?: string }
46}
47
48export type ShowBannerValue = true | 'force'
49
50export interface BannerIncident {
51 id: string
52 show_banner: ShowBannerValue
53 metadata: z.infer<typeof MetadataSchema> & { force: boolean }
54}
55
56function getFieldValue(entries: Array<CustomFieldEntry>, fieldId: string): string | undefined {
57 const entry = entries.find((e) => e.custom_field.id === fieldId)
58 if (!entry || entry.values.length === 0) return undefined
59 const val = entry.values[0]
60 return val.value_option?.value ?? val.value_text ?? val.value_numeric
61}
62
63async function fetchAllIncidents(apiKey: string, mode: string): Promise<Array<Incident>> {
64 const incidents: Array<Incident> = []
65 let after: string | undefined
66
67 do {
68 const params = new URLSearchParams()
69 params.append('status_category[one_of]', 'live')
70 params.append('severity[gte]', MINOR_SEVERITY_ID)
71 params.append('mode[one_of]', mode)
72 params.set('page_size', '25')
73 if (after) params.set('after', after)
74
75 const response = await fetch(`${INCIDENT_IO_BASE_URL}/incidents?${params}`, {
76 headers: {
77 Authorization: `Bearer ${apiKey}`,
78 'Content-Type': 'application/json',
79 },
80 next: { revalidate: 180 },
81 signal: AbortSignal.timeout(30_000),
82 })
83
84 if (!response.ok) {
85 const retryAfter = response.headers.get('Retry-After') ?? undefined
86 const body = await response.text()
87 throw new InternalServerError(`incident.io API responded with ${response.status}`, {
88 status: response.status,
89 body,
90 ...(retryAfter !== undefined && { retryAfter }),
91 })
92 }
93
94 const data: IncidentIoListResponse = await response.json()
95 incidents.push(...data.incidents)
96 after = data.pagination_meta?.after
97 } while (after)
98
99 return incidents
100}
101
102/**
103 * Fetches active banner incidents from the incident.io API.
104 *
105 * @returns Array of banner incidents
106 * @throws Error if INCIDENT_IO_API_KEY is not set or the API returns an error
107 */
108export async function getBannerIncidents(): Promise<Array<BannerIncident>> {
109 const apiKey = process.env.INCIDENT_IO_API_KEY
110 if (!apiKey) {
111 throw new Error('INCIDENT_IO_API_KEY is not set')
112 }
113
114 const incidentMode = IS_PROD ? 'standard' : 'test'
115 const allIncidents = await fetchAllIncidents(apiKey, incidentMode)
116
117 const bannerIncidents: Array<BannerIncident> = []
118
119 for (const incident of allIncidents) {
120 const bannerValue = getFieldValue(incident.custom_field_entries, BANNER_FIELD_ID)
121 if (bannerValue !== SENTINEL_VALUE_SHOW_BANNER && bannerValue !== SENTINEL_VALUE_FORCE_BANNER) {
122 continue
123 }
124
125 const metadataRaw = getFieldValue(incident.custom_field_entries, METADATA_FIELD_ID)
126
127 let parsedJson: unknown = null
128 try {
129 parsedJson = JSON.parse(metadataRaw ?? 'null')
130 } catch {
131 // malformed JSON — fall through to default metadata
132 }
133 const parsed = MetadataSchema.safeParse(parsedJson)
134 const metadata: z.infer<typeof MetadataSchema> = parsed.success
135 ? parsed.data
136 : FALLBACK_METADATA
137
138 bannerIncidents.push({
139 id: createHash('sha256').update(incident.created_at).digest('hex'),
140 show_banner: bannerValue === SENTINEL_VALUE_FORCE_BANNER ? 'force' : true,
141 metadata: { ...metadata, force: bannerValue === SENTINEL_VALUE_FORCE_BANNER },
142 })
143 }
144
145 return bannerIncidents
146}