'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { ApiError } from '@/lib/api'; import { AdminAuthError, adminApi, claimHistory } from './admin-api'; import { clearAdminToken, setAdminToken } from './session'; /** * Server actions for the admin console. Each mutating action calls the API with the cookie token, revalidates the * section and redirects back with a `?notice=` message (no client state, works without JS). */ function describe(e: unknown): string { if (e instanceof AdminAuthError) return 'Session expired — sign in again.'; if (e instanceof ApiError) return e.detail ? `API ${e.status}: ${e.detail}` : e.message; return (e as Error)?.message ?? 'Unknown error'; } function back(path: string, notice: string, ok = true): never { const p = new URLSearchParams(); p.set('notice', notice); if (!ok) p.set('level', 'error'); redirect(`${path}${path.includes('?') ? '&' : '?'}${p.toString()}`); } async function run(path: string, okMsg: (r: unknown) => string, fn: () => Promise): Promise { let msg: string; let ok = true; try { const r = await fn(); msg = okMsg(r); } catch (e) { if (e instanceof AdminAuthError) redirect('/admin?expired=1'); msg = describe(e); ok = false; } revalidatePath(path.split('?')[0] ?? path); back(path, msg, ok); } // ------------------------------------------------------------------------------------------------------------ auth export async function loginAction(_prev: { error: string | null } | undefined, formData: FormData): Promise<{ error: string | null }> { const token = String(formData.get('token') ?? '').trim(); if (!token) return { error: 'Enter the admin token.' }; try { await adminApi.overview(token); } catch (e) { if (e instanceof AdminAuthError) return { error: 'Token rejected by the API.' }; return { error: `API unreachable: ${describe(e)}` }; } await setAdminToken(token); redirect('/admin/overview'); } export async function logoutAction(): Promise { await clearAdminToken(); redirect('/admin?signed_out=1'); } // ------------------------------------------------------------------------------------------------------------ connectors export async function runConnectorAction(formData: FormData): Promise { const name = String(formData.get('name') ?? ''); const ret = String(formData.get('return') ?? '/admin/connectors'); await run(ret, () => `Run queued for ${name} — the scheduler picks it up on its next tick.`, () => adminApi.runConnector(name, true)); } export async function toggleConnectorAction(formData: FormData): Promise { const name = String(formData.get('name') ?? ''); const enabled = String(formData.get('enabled') ?? '') === 'true'; const ret = String(formData.get('return') ?? '/admin/connectors'); await run(ret, () => `${name} ${enabled ? 'enabled' : 'disabled'}.`, () => adminApi.patchConnector(name, { enabled })); } // ------------------------------------------------------------------------------------------------------------ jobs export async function retryJobAction(formData: FormData): Promise { const id = String(formData.get('id') ?? ''); const ret = String(formData.get('return') ?? '/admin/jobs'); await run(ret, () => `Job ${id} re-queued.`, () => adminApi.retryJob(id)); } export async function requeueDeadAction(formData: FormData): Promise { const ret = String(formData.get('return') ?? '/admin/jobs'); await run( ret, (r) => { const n = (r as { requeued?: number; count?: number } | null)?.requeued ?? (r as { count?: number } | null)?.count; return n === undefined ? 'Dead jobs re-queued.' : `${n} dead job${n === 1 ? '' : 's'} re-queued.`; }, () => adminApi.requeueDead(), ); } // ------------------------------------------------------------------------------------------------------------ review export async function reviewAction(formData: FormData): Promise { const id = String(formData.get('id') ?? ''); const action = String(formData.get('action') ?? '') as 'approve' | 'reject'; const ret = String(formData.get('return') ?? '/admin/review'); if (action !== 'approve' && action !== 'reject') back(ret, 'Unknown action.', false); await run(ret, (r) => `Review ${id} ${(r as { status?: string })?.status ?? action}.`, () => adminApi.reviewAction(id, action)); } /** * Conflict resolution: the payload only carries the two values and their source URLs, not claim ids. We look the * claim up in the entity's claim history (same property, same value, same source URL — newest first) and approve * with `resolution.keep_claim_id` so the API promotes it and supersedes the other. */ export async function keepConflictSideAction(formData: FormData): Promise { const id = String(formData.get('id') ?? ''); const slug = String(formData.get('slug') ?? ''); const property = String(formData.get('property') ?? ''); const valueJson = String(formData.get('value') ?? 'null'); const sourceUrl = String(formData.get('source_url') ?? ''); const ret = String(formData.get('return') ?? '/admin/review'); await run( ret, () => `Conflict ${id} resolved — kept ${property} = ${valueJson}.`, async () => { let value: unknown = null; try { value = JSON.parse(valueJson); } catch { value = valueJson; } const hist = await claimHistory(slug, property); const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const match = hist.items.find((c) => same(c.value, value) && (!sourceUrl || c.source_url === sourceUrl)) ?? hist.items.find((c) => same(c.value, value)); if (!match) throw new Error(`No claim with ${property} = ${valueJson} found in the history of ${slug}; approve or reject without promotion instead.`); return adminApi.reviewAction(id, 'approve', { keep_claim_id: match.id, kept_value: value, kept_source: sourceUrl || null }); }, ); } // ------------------------------------------------------------------------------------------------------------ duplicates export async function mergeAction(formData: FormData): Promise { const source = String(formData.get('source_id') ?? ''); const target = String(formData.get('target_id') ?? ''); const ret = String(formData.get('return') ?? '/admin/entities/duplicates'); await run(ret, () => `Merged ${source} → ${target}.`, () => adminApi.merge(source, target)); } // ------------------------------------------------------------------------------------------------------------ maintenance export async function flushCacheAction(): Promise { await run('/admin/cache', (r) => `Cache flushed (${(r as { flushed?: number })?.flushed ?? '?'} keys).`, () => adminApi.flushCache()); } export async function recomputeStatsAction(): Promise { await run('/admin/cache', () => 'Stats recompute triggered.', () => adminApi.recomputeStats()); } export async function recomputeQualityAction(): Promise { await run('/admin/cache', () => 'Quality recompute triggered.', () => adminApi.recomputeQuality()); }