incident-banner.ts 4.4 KB

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