SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.4 KB · 89 lines typescript
Raw Blame History
1'use server';23import { revalidatePath } from 'next/cache';4import { redirect } from 'next/navigation';5import { normalizeLabel } from '@cancerindex/shared';6import { db, sql } from '@/lib/db';7import { isAdmin } from './auth';89/**10 * Admin mutations (server actions). Every mutation writes audit_log (§348).11 * Resolve: add alias on the target entity, mark the label mapped, log. Reject: mark rejected, log.12 */13export type ActionResult = { ok: true; message: string } | { ok: false; message: string };1415async function guard(): Promise<ActionResult | null> {16  if (!(await isAdmin())) return { ok: false, message: 'Not authorized' };17  return null;18}1920export async function resolveUnresolved(formData: FormData): Promise<ActionResult> {21  const denied = await guard();22  if (denied) return denied;23  const id = Number(formData.get('id'));24  const targetId = String(formData.get('targetId') ?? '').trim();25  const reason = String(formData.get('reason') ?? '').trim() || null;26  const actor = 'admin';27  if (!Number.isFinite(id) || !/^CI-[A-Z]+-\d{8,}$/.test(targetId)) return { ok: false, message: 'Invalid id or target' };2829  try {30    await db().transaction(async (tx) => {31      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`);32      if (!row) throw new Error('label not found');33      if (row.entity_kind === 'cancer') {34        const [c] = await tx.execute<{ id: string }>(sql`SELECT id FROM cancers WHERE id = ${targetId}`);35        if (!c) throw new Error('target cancer not found');36        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`);37      } else if (row.entity_kind === 'drug') {38        const [d] = await tx.execute<{ id: string }>(sql`SELECT id FROM drugs WHERE id = ${targetId}`);39        if (!d) throw new Error('target drug not found');40        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`);41      } else if (row.entity_kind === 'gene') {42        const [g] = await tx.execute<{ id: string }>(sql`SELECT id FROM genes WHERE id = ${targetId}`);43        if (!g) throw new Error('target gene not found');44        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`);45      } else {46        throw new Error(`unsupported entity kind ${row.entity_kind}`);47      }48      await tx.execute(sql`UPDATE unresolved_labels SET status = 'mapped', resolved_id = ${targetId}, resolved_by = ${actor}, updated_at = now() WHERE id = ${id}`);49      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})`);50    });51    revalidatePath('/admin/unresolved');52    return { ok: true, message: `Label #${id} mapped to ${targetId}` };53  } catch (e) {54    return { ok: false, message: (e as Error).message };55  }56}5758export async function rejectUnresolved(formData: FormData): Promise<ActionResult> {59  const denied = await guard();60  if (denied) return denied;61  const id = Number(formData.get('id'));62  const reason = String(formData.get('reason') ?? '').trim() || null;63  const status = String(formData.get('status') ?? 'rejected') === 'ignored' ? 'ignored' : 'rejected';64  if (!Number.isFinite(id)) return { ok: false, message: 'Invalid id' };65  try {66    await db().transaction(async (tx) => {67      const [row] = await tx.execute<{ status: string; source_text: string }>(sql`SELECT status, source_text FROM unresolved_labels WHERE id = ${id} FOR UPDATE`);68      if (!row) throw new Error('label not found');69      await tx.execute(sql`UPDATE unresolved_labels SET status = ${status}, resolved_by = 'admin', updated_at = now() WHERE id = ${id}`);70      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})`);71    });72    revalidatePath('/admin/unresolved');73    return { ok: true, message: `Label #${id} marked ${status}` };74  } catch (e) {75    return { ok: false, message: (e as Error).message };76  }77}7879/** Form-compatible wrappers: run the mutation, then redirect back with the outcome in the URL. */80export async function resolveAction(formData: FormData): Promise<void> {81  const r = await resolveUnresolved(formData);82  redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`);83}8485export async function rejectAction(formData: FormData): Promise<void> {86  const r = await rejectUnresolved(formData);87  redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`);88}89