incident-banner.ts146 lines · main
| 1 | import { createHash } from 'crypto' |
| 2 | import { IS_PROD } from 'common' |
| 3 | import z from 'zod' |
| 4 | |
| 5 | import { InternalServerError } from '@/lib/api/apiHelpers' |
| 6 | |
| 7 | const INCIDENT_IO_BASE_URL = 'https://api.incident.io/v2' |
| 8 | |
| 9 | const BANNER_FIELD_ID = '01KKCFNW31EGRMD3JQ58E2TJ2M' |
| 10 | const METADATA_FIELD_ID = '01KKCD4KNWQ7HYSXT72CHB7WR4' |
| 11 | |
| 12 | const MINOR_SEVERITY_ID = '01J7BTA8DEF371JQSXGBZYZY7D' |
| 13 | |
| 14 | const SENTINEL_VALUE_SHOW_BANNER = '1' |
| 15 | const SENTINEL_VALUE_FORCE_BANNER = '100' |
| 16 | |
| 17 | const FALLBACK_METADATA = { affected_regions: null, affects_project_creation: false } |
| 18 | |
| 19 | const MetadataSchema = z.object({ |
| 20 | affected_regions: z.union([z.array(z.string()), z.null()]), |
| 21 | affects_project_creation: z.boolean(), |
| 22 | }) |
| 23 | |
| 24 | interface CustomFieldValue { |
| 25 | value_option?: { id: string; value: string } |
| 26 | value_text?: string |
| 27 | value_numeric?: string |
| 28 | } |
| 29 | |
| 30 | interface CustomFieldEntry { |
| 31 | custom_field: { id: string } |
| 32 | values: Array<CustomFieldValue> |
| 33 | } |
| 34 | |
| 35 | interface Incident { |
| 36 | id: string |
| 37 | name: string |
| 38 | mode: string |
| 39 | created_at: string |
| 40 | custom_field_entries: Array<CustomFieldEntry> |
| 41 | } |
| 42 | |
| 43 | interface IncidentIoListResponse { |
| 44 | incidents: Array<Incident> |
| 45 | pagination_meta?: { after?: string } |
| 46 | } |
| 47 | |
| 48 | export type ShowBannerValue = true | 'force' |
| 49 | |
| 50 | export interface BannerIncident { |
| 51 | id: string |
| 52 | show_banner: ShowBannerValue |
| 53 | metadata: z.infer<typeof MetadataSchema> & { force: boolean } |
| 54 | } |
| 55 | |
| 56 | function 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 | |
| 63 | async 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 | */ |
| 108 | export 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 | } |