import 'server-only'; import { redirect } from 'next/navigation'; import { API_URL, ApiError, type Query } from '@/lib/api'; import { getAdminToken } from './session'; import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminQuality, AdminRun, AdminSnapshot, AnomaliesPayload, AuditPayload, DuplicatesPayload, ExtractionPayload, Infrastructure, QuarantinePayload, ResolutionDecision, ResolutionPayload, ReviewPayload, RollbackResult } from './types'; /** * Server-only admin client. Every call carries `x-aia-admin-token` from the httpOnly cookie (or an explicit token * during login) and is `no-store`. A 401/403 raises `AdminAuthError`; pages turn it into a redirect to the login form. */ const BASE = `${API_URL}/api/v1/admin`; export class AdminAuthError extends Error { constructor() { super('admin token missing or rejected'); this.name = 'AdminAuthError'; } } function qs(query?: Query): string { if (!query) return ''; const p = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v === undefined || v === null || v === '') continue; p.set(k, String(v)); } const s = p.toString(); return s ? `?${s}` : ''; } export async function adminRequest(path: string, opts: { method?: 'GET' | 'POST' | 'PATCH'; query?: Query; body?: unknown; token?: string } = {}): Promise { const token = opts.token ?? (await getAdminToken()); if (!token) throw new AdminAuthError(); const url = `${BASE}${path}${qs(opts.query)}`; const init: RequestInit = { method: opts.method ?? 'GET', cache: 'no-store', headers: { accept: 'application/json', 'x-aia-admin-token': token } }; if (opts.body !== undefined) { init.body = JSON.stringify(opts.body); (init.headers as Record)['content-type'] = 'application/json'; } let res: Response; try { res = await fetch(url, init); } catch (e) { throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`); } if (res.status === 401 || res.status === 403) throw new AdminAuthError(); if (!res.ok) { let detail: string | null = null; try { const body = (await res.json()) as { detail?: unknown }; detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null; } catch { /* non-JSON error body */ } throw new ApiError(res.status, path, detail); } return (await res.json()) as T; } /** Ensure a session cookie exists; otherwise go to the login form. */ export async function requireAdmin(): Promise { const token = await getAdminToken(); if (!token) redirect('/admin'); return token; } export type Loaded = { ok: true; data: T } | { ok: false; error: string }; /** Resolve an admin fetch for a page panel: auth failure → login redirect; other failures → `{ ok: false, error }`. */ export async function load(p: Promise): Promise> { try { return { ok: true, data: await p }; } catch (e) { if (e instanceof AdminAuthError) redirect('/admin?expired=1'); const msg = e instanceof ApiError ? (e.detail ? `${e.status}: ${e.detail}` : e.message) : (e as Error).message; return { ok: false, error: msg }; } } export const adminApi = { overview: (token?: string) => adminRequest('/overview', { token }), connectors: () => adminRequest('/connectors'), runConnector: (name: string, force = true) => adminRequest<{ queued: boolean }>(`/connectors/${encodeURIComponent(name)}/run`, { method: 'POST', body: { force } }), patchConnector: (name: string, patch: { enabled?: boolean; interval_seconds?: number; priority?: number }) => adminRequest(`/connectors/${encodeURIComponent(name)}`, { method: 'PATCH', body: patch }), runs: (query: Query) => adminRequest>('/runs', { query }), errors: (query: Query) => adminRequest>('/errors', { query }), documents: (query: Query) => adminRequest>('/documents', { query }), document: (id: string) => adminRequest(`/documents/${encodeURIComponent(id)}`), snapshot: (id: string) => adminRequest(`/snapshots/${encodeURIComponent(id)}`), jobs: (query: Query) => adminRequest('/jobs', { query }), retryJob: (id: string) => adminRequest(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: {} }), requeueDead: () => adminRequest<{ requeued?: number } & Record>('/jobs/requeue-dead', { method: 'POST', body: {} }), llmJobs: (query: Query) => adminRequest('/llm-jobs', { query }), llmHealth: () => adminRequest('/llm/health'), review: (query: Query) => adminRequest('/review', { query }), reviewAction: (id: string, action: 'approve' | 'reject' | 'edit', resolution?: Record) => adminRequest<{ ok: boolean; status: string; effect?: unknown }>(`/review/${encodeURIComponent(id)}`, { method: 'POST', body: { action, resolution } }), duplicates: (query: Query) => adminRequest('/entities/duplicates', { query }), merge: (source_id: string, target_id: string) => adminRequest('/entities/merge', { method: 'POST', body: { source_id, target_id } }), infrastructure: () => adminRequest('/infrastructure'), flushCache: () => adminRequest<{ flushed: number }>('/cache/flush', { method: 'POST', body: {} }), recomputeStats: () => adminRequest('/stats/recompute', { method: 'POST', body: {} }), recomputeQuality: () => adminRequest('/quality/recompute', { method: 'POST', body: {} }), // ---- D3 workbenches (1.1) quality: () => adminRequest('/quality'), entityResolution: (query: Query) => adminRequest('/entity-resolution', { query }), resolve: (a: string, b: string, decision: ResolutionDecision, note?: string) => adminRequest<{ ok: boolean; a: string; b: string; decision: string; applied: boolean; effect?: unknown }>(`/entity-resolution/${encodeURIComponent(a)}/${encodeURIComponent(b)}`, { method: 'POST', body: { decision, note: note || undefined } }), anomalies: (query: Query) => adminRequest('/anomalies', { query }), anomalyAction: (id: string, status: 'resolved' | 'ignored' | 'open', note?: string) => adminRequest(`/anomalies/${encodeURIComponent(id)}`, { method: 'POST', body: { status, note: note || undefined } }), extraction: (snapshotId: string, textLimit = 20000) => adminRequest(`/extractions/${encodeURIComponent(snapshotId)}`, { query: { text_limit: textLimit } }), quarantine: (query: Query) => adminRequest('/quarantine', { query }), quarantineAction: (id: string, action: 'release' | 'discard', note?: string) => adminRequest(`/quarantine/${encodeURIComponent(id)}`, { method: 'POST', body: { action, note: note || undefined } }), audit: (query: Query) => adminRequest('/audit', { query }), rollback: (runId: string) => adminRequest(`/runs/${encodeURIComponent(runId)}/rollback`, { method: 'POST', body: {} }), }; /** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */ export async function claimHistory(slug: string, property: string): Promise<{ items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }> { const res = await fetch(`${API_URL}/api/v1/entities/${encodeURIComponent(slug)}/history?property=${encodeURIComponent(property)}`, { cache: 'no-store', headers: { accept: 'application/json' } }); if (!res.ok) throw new ApiError(res.status, '/history', null); return (await res.json()) as { items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }; }