incident-status.ts 4.0 KB

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