Files
accounted/app/api/health/route.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

122 lines
4.1 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logger'
const log = createLogger('health')
const CACHE_TTL_MS = 5_000
type HealthBody = {
status: 'healthy' | 'unhealthy'
timestamp: string
version: string
}
type CheckResult = {
body: HealthBody
status: number
}
type CachedResult = CheckResult & { expires: number }
// In-memory cache shared across requests in the same process. Docker's
// healthcheck polls every 30 s, so the cache always returns fresh data to it,
// but a public flood (multiple requests/second) is served from RAM and never
// reaches Postgres. The cache is intentionally tiny (one entry) because the
// endpoint takes no parameters.
let cached: CachedResult | null = null
// Holds the pending check when one is in flight so concurrent cache misses
// share a single Postgres round-trip. Cleared as soon as the promise settles.
// Bounds the worst case to one DB query per CACHE_TTL_MS window regardless of
// burst arrival rate (e.g. a load-balancer replaying queued probes).
let pending: Promise<CheckResult> | null = null
/**
* GET /api/health
* Public health check endpoint (no auth required).
*
* Error details are logged server-side only: never echoed to the response
* body, which would expose Postgres error text on a public endpoint. The
* logger receives only error.code/error.message; raw Supabase error objects
* may include schema names, table names, or query fragments that should
* never reach application logs.
*
* Results are cached for {@link CACHE_TTL_MS} so flood traffic does not
* hammer Postgres with a service-role query per request.
*/
export async function GET() {
const now = Date.now()
if (cached && cached.expires > now) {
return NextResponse.json(cached.body, { status: cached.status })
}
const inFlight = pending ?? (pending = runAndCache(now))
try {
const result = await inFlight
return NextResponse.json(result.body, { status: result.status })
} finally {
if (pending === inFlight) pending = null
}
}
async function runAndCache(now: number): Promise<CheckResult> {
const result = await runHealthCheck()
cached = { ...result, expires: now + CACHE_TTL_MS }
return result
}
async function runHealthCheck(): Promise<CheckResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
log.error('Missing Supabase configuration for health check')
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
try {
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { error } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
.limit(1)
if (error) {
// PostgrestError is a plain object: { code, message, details, hint }.
// details/hint can contain table or column names; log only the
// operationally useful fields.
log.error('Database health check failed', {
errCode: error.code ?? null,
errMessage: error.message ?? null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
return {
body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 200,
}
} catch (err) {
// Caught Error instances are reduced to {name, message, code} by the
// logger's redactor; never pass the raw value lest a deep stack containing
// query strings ends up in production logs.
const e = err as { name?: unknown; message?: unknown; code?: unknown }
log.error('Health check unexpected error', {
errName: typeof e?.name === 'string' ? e.name : null,
errMessage: typeof e?.message === 'string' ? e.message : null,
errCode: typeof e?.code === 'string' ? e.code : null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
}