'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { normalizeLabel } from '@cancerindex/shared'; import { db, sql } from '@/lib/db'; import { isAdmin } from './auth'; /** * Admin mutations (server actions). Every mutation writes audit_log (ยง348). * Resolve: add alias on the target entity, mark the label mapped, log. Reject: mark rejected, log. */ export type ActionResult = { ok: true; message: string } | { ok: false; message: string }; async function guard(): Promise { if (!(await isAdmin())) return { ok: false, message: 'Not authorized' }; return null; } export async function resolveUnresolved(formData: FormData): Promise { const denied = await guard(); if (denied) return denied; const id = Number(formData.get('id')); const targetId = String(formData.get('targetId') ?? '').trim(); const reason = String(formData.get('reason') ?? '').trim() || null; const actor = 'admin'; if (!Number.isFinite(id) || !/^CI-[A-Z]+-\d{8,}$/.test(targetId)) return { ok: false, message: 'Invalid id or target' }; try { await db().transaction(async (tx) => { const [row] = await tx.execute<{ id: number; entity_kind: string; source_text: string; normalized: string; source_id: string; status: string }>(sql`SELECT id, entity_kind, source_text, normalized, source_id, status FROM unresolved_labels WHERE id = ${id} FOR UPDATE`); if (!row) throw new Error('label not found'); if (row.entity_kind === 'cancer') { const [c] = await tx.execute<{ id: string }>(sql`SELECT id FROM cancers WHERE id = ${targetId}`); if (!c) throw new Error('target cancer not found'); await tx.execute(sql`INSERT INTO cancer_aliases (cancer_id, alias, normalized, alias_type, source_id, source_terminology) VALUES (${targetId}, ${row.source_text}, ${normalizeLabel(row.source_text)}, 'synonym', ${row.source_id}, 'curator') ON CONFLICT DO NOTHING`); } else if (row.entity_kind === 'drug') { const [d] = await tx.execute<{ id: string }>(sql`SELECT id FROM drugs WHERE id = ${targetId}`); if (!d) throw new Error('target drug not found'); await tx.execute(sql`INSERT INTO drug_aliases (drug_id, alias, normalized, alias_type, source_id) VALUES (${targetId}, ${row.source_text}, ${normalizeLabel(row.source_text)}, 'synonym', ${row.source_id}) ON CONFLICT DO NOTHING`); } else if (row.entity_kind === 'gene') { const [g] = await tx.execute<{ id: string }>(sql`SELECT id FROM genes WHERE id = ${targetId}`); if (!g) throw new Error('target gene not found'); await tx.execute(sql`INSERT INTO gene_aliases (gene_id, alias, alias_type, source_id) VALUES (${targetId}, ${row.source_text}, 'alias_symbol', ${row.source_id}) ON CONFLICT DO NOTHING`); } else { throw new Error(`unsupported entity kind ${row.entity_kind}`); } await tx.execute(sql`UPDATE unresolved_labels SET status = 'mapped', resolved_id = ${targetId}, resolved_by = ${actor}, updated_at = now() WHERE id = ${id}`); await tx.execute(sql`INSERT INTO audit_log (actor, action, entity_type, entity_id, before, after, reason) VALUES (${actor}, 'unresolved.resolve', 'unresolved_label', ${String(id)}, ${JSON.stringify({ status: row.status, sourceText: row.source_text })}::jsonb, ${JSON.stringify({ status: 'mapped', resolvedId: targetId, aliasAdded: true })}::jsonb, ${reason})`); }); revalidatePath('/admin/unresolved'); return { ok: true, message: `Label #${id} mapped to ${targetId}` }; } catch (e) { return { ok: false, message: (e as Error).message }; } } export async function rejectUnresolved(formData: FormData): Promise { const denied = await guard(); if (denied) return denied; const id = Number(formData.get('id')); const reason = String(formData.get('reason') ?? '').trim() || null; const status = String(formData.get('status') ?? 'rejected') === 'ignored' ? 'ignored' : 'rejected'; if (!Number.isFinite(id)) return { ok: false, message: 'Invalid id' }; try { await db().transaction(async (tx) => { const [row] = await tx.execute<{ status: string; source_text: string }>(sql`SELECT status, source_text FROM unresolved_labels WHERE id = ${id} FOR UPDATE`); if (!row) throw new Error('label not found'); await tx.execute(sql`UPDATE unresolved_labels SET status = ${status}, resolved_by = 'admin', updated_at = now() WHERE id = ${id}`); await tx.execute(sql`INSERT INTO audit_log (actor, action, entity_type, entity_id, before, after, reason) VALUES ('admin', ${`unresolved.${status}`}, 'unresolved_label', ${String(id)}, ${JSON.stringify({ status: row.status, sourceText: row.source_text })}::jsonb, ${JSON.stringify({ status })}::jsonb, ${reason})`); }); revalidatePath('/admin/unresolved'); return { ok: true, message: `Label #${id} marked ${status}` }; } catch (e) { return { ok: false, message: (e as Error).message }; } } /** Form-compatible wrappers: run the mutation, then redirect back with the outcome in the URL. */ export async function resolveAction(formData: FormData): Promise { const r = await resolveUnresolved(formData); redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`); } export async function rejectAction(formData: FormData): Promise { const r = await rejectUnresolved(formData); redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`); }