Files
supabase/apps/studio/lib/api/incident-status.ts
Charis a4641d0b9f refactor: move /incident-status to app router (#43881)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Refactor

## What is the current behavior?

`/incident-status` is handled via Pages Router.

## What is the new behavior?

`/incident-status` is handled via App Router, enabling use of Vercel
Data Cache to cache the upstream fetch.

## Additional context

Adding the first App Router route handler triggered `next typegen` (run
as `pretypecheck`) to generate `.next/dev/types/validator.ts`, which
imports all route files and expanded the type-checked graph. This
surfaced pre-existing `null`-safety errors in:

- `components/grid/SupabaseGrid.utils.ts` — `useSearchParams()` result
- `components/layouts/ProjectLayout/UpgradingState/index.tsx` —
`useSearchParams()` result
- `pages/project/[ref]/sql/quickstarts.tsx` — `useParams()` result
- `pages/project/[ref]/sql/templates.tsx` — `useParams()` result

These are fixed with optional chaining. The `tsconfig.json` change
(adding `.next/dev/types/**/*.ts` to `include`) is auto-generated by
Next.js and committed as correct behavior.
2026-03-18 09:36:19 +01:00

146 lines
4.0 KiB
TypeScript

import { IS_PLATFORM } from 'common'
import { InternalServerError } from 'lib/api/apiHelpers'
import z from 'zod'
export type IncidentCache = {
affected_regions: Array<string> | null
affects_project_creation: boolean
/** When true, the banner is shown unconditionally regardless of regions or project state. */
force?: boolean
}
export type IncidentMetadata = {
dashboard_metadata?: {
show_banner?: boolean
}
}
export type IncidentInfo = {
id: string
name: string
status: string
impact: string
active_since: string
metadata: IncidentMetadata
cache?: IncidentCache | null
}
const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1'
const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID
const STATUSPAGE_API_KEY = process.env.STATUSPAGE_API_KEY
function getIncidentsEndpoint(): string {
return `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved`
}
const StatusPageIncidentsSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
status: z.string(),
created_at: z.string(),
scheduled_for: z.string().nullable(),
impact: z.string(),
metadata: z
.object({
dashboard_metadata: z
.object({
show_banner: z.boolean().optional(),
})
.optional(),
})
.optional()
.default({}),
})
)
/**
* Fetches active incidents from the StatusPage API.
* This function is used both by the API route and the AI assistant.
*
* @returns Array of active incidents
* @throws InternalServerError if StatusPage is not configured or returns an error
*/
export async function getActiveIncidents(): Promise<IncidentInfo[]> {
if (!IS_PLATFORM) {
return []
}
if (!STATUSPAGE_PAGE_ID) {
throw new InternalServerError('StatusPage page ID is not configured')
}
if (!STATUSPAGE_API_KEY) {
throw new InternalServerError('StatusPage API key is not configured')
}
const response = await fetch(getIncidentsEndpoint(), {
headers: {
Authorization: `OAuth ${STATUSPAGE_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
next: { revalidate: 180 },
signal: AbortSignal.timeout(30_000),
})
const responseText = await response.text()
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After') ?? undefined
throw new InternalServerError(`StatusPage API responded with ${response.status}`, {
status: response.status,
body: responseText,
...(retryAfter !== undefined && { retryAfter }),
})
}
let incidentsJson: unknown
try {
incidentsJson = JSON.parse(responseText)
} catch (error) {
throw new InternalServerError('StatusPage API response could not be parsed as JSON', {
error: error instanceof Error ? error.message : error,
body: responseText,
})
}
const result = StatusPageIncidentsSchema.safeParse(incidentsJson)
if (!result.success) {
throw new InternalServerError('StatusPage API response did not match expected schema', {
issues: result.error.issues,
})
}
const now = Date.now()
const activeIncidents = result.data.filter((incident) => {
const hasNoScheduledTime = !incident.scheduled_for
if (hasNoScheduledTime) {
return true
}
const scheduledTime = Date.parse(incident.scheduled_for!)
const isScheduledTimeInvalid = Number.isNaN(scheduledTime)
if (isScheduledTimeInvalid) {
// Keep the record but note it locally for debugging
console.warn('Encountered incident with invalid scheduled_for date', {
incidentId: incident.id,
scheduled_for: incident.scheduled_for,
})
return true
}
const hasScheduledTimePassed = scheduledTime <= now
return hasScheduledTimePassed
})
return activeIncidents.map((incident) => ({
id: incident.id,
name: incident.name,
status: incident.status,
impact: incident.impact,
active_since: incident.scheduled_for ?? incident.created_at,
metadata: incident.metadata,
}))
}