"use server"; import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { z } from "zod"; import { sql } from "@/db"; import { requireAction } from "@/lib/auth/guard"; import { clientIp, revokeOtherSessions, revokeSession, clearSessionCookie } from "@/lib/auth/session"; import { hashPassword, passwordPolicy, verifyPassword } from "@/lib/auth/password"; import { audit } from "@/lib/audit"; import { enqueue } from "@/lib/jobs/queue"; import { makeIdentifier, parseVersionLabel } from "@/lib/papers/identifiers"; import { lexvecFromMetadata } from "@/lib/jobs/handlers"; import { verifySync } from "otplib"; const s = (f: FormData, k: string) => { const v = f.get(k); return typeof v === "string" ? v.trim() : ""; }; const opt = (f: FormData, k: string) => s(f, k) || null; const list = (f: FormData, k: string) => f.getAll(k).map(String).filter(Boolean); const lines = (f: FormData, k: string) => s(f, k).split(/\r?\n/).map((x) => x.trim()).filter(Boolean); const jsonField = (f: FormData, k: string, fallback: T): T => { const v = s(f, k); if (!v) return fallback; try { return JSON.parse(v) as T; } catch { throw new Error(`Field ${k} is not valid JSON`); } }; const dateField = (f: FormData, k: string): { date: string | null; precision: "day" | "month" | "year" } => { const v = s(f, k); if (!v) return { date: null, precision: "month" }; if (/^\d{4}$/.test(v)) return { date: `${v}-01-01`, precision: "year" }; if (/^\d{4}-\d{2}$/.test(v)) return { date: `${v}-01`, precision: "month" }; if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return { date: v, precision: "day" }; throw new Error(`Date ${k} must be YYYY, YYYY-MM or YYYY-MM-DD`); }; const actor = async (u: { id: string; email: string }) => ({ id: u.id, label: u.email, ip: clientIp(await headers()) }); function revalidateAll(slug?: string) { for (const p of ["/", "/papers", "/topics", "/methods", "/datasets", "/authors", "/series", "/timeline", "/research-map", "/sitemap.xml", "/feed.xml"]) revalidatePath(p); if (slug) { revalidatePath(`/papers/${slug}`); revalidatePath(`/papers/${slug}/read`); } revalidatePath("/", "layout"); } const PaperSchema = z.object({ title: z.string().min(3), number: z.number().int().positive(), series_code: z.string().min(1) }); async function savePaperFields(f: FormData, id: string | null, u: { id: string; email: string }) { const [series] = await sql`SELECT id, code FROM series WHERE code = ${s(f, "series_code").toUpperCase()}`; if (!series) throw new Error("Unknown series"); const number = Number(s(f, "number")); PaperSchema.parse({ title: s(f, "title"), number, series_code: series.code }); const idp = makeIdentifier(series.code, number); const first = dateField(f, "first_released_on"); const rev = dateField(f, "last_revised_on"); const row = { series_id: series.id, number, identifier: idp.identifier, slug: idp.slug, internal_code: opt(f, "internal_code"), title: s(f, "title"), subtitle: opt(f, "subtitle"), title_fr: opt(f, "title_fr"), subtitle_fr: opt(f, "subtitle_fr"), abstract: sql.json(s(f, "abstract").split(/\n\s*\n/).map((x) => x.replace(/\s+/g, " ").trim()).filter(Boolean)), abstract_fr: s(f, "abstract_fr") ? sql.json(s(f, "abstract_fr").split(/\n\s*\n/).map((x) => x.replace(/\s+/g, " ").trim()).filter(Boolean)) : null, language: s(f, "language") || "en", research_question: opt(f, "research_question"), research_question_fr: opt(f, "research_question_fr"), hero_headline: opt(f, "hero_headline"), hero_subheadline: opt(f, "hero_subheadline"), notice: opt(f, "notice"), notice_fr: opt(f, "notice_fr"), contributions: sql.json(jsonField(f, "contributions", [])), methodology: sql.json(jsonField(f, "methodology", [])), data_sections: sql.json(jsonField(f, "data_sections", [])), findings: sql.json(jsonField(f, "findings", [])), reproducibility: sql.json(lines(f, "reproducibility")), artifacts: sql.json(jsonField(f, "artifacts", [])), external_links: sql.json(jsonField(f, "external_links", [])), keywords: lines(f, "keywords"), jel: s(f, "jel").split(/[,\s]+/).filter(Boolean), publication_status: s(f, "publication_status") || "working_paper", journal_ref: opt(f, "journal_ref"), doi: opt(f, "doi"), ssrn_id: opt(f, "ssrn_id"), year: s(f, "year") ? Number(s(f, "year")) : null, first_released_on: first.date, first_released_precision: first.precision, last_revised_on: rev.date ?? first.date, last_revised_precision: rev.date ? rev.precision : first.precision, featured: s(f, "featured") === "on", featured_rank: s(f, "featured_rank") ? Number(s(f, "featured_rank")) : null, primary_domain_id: s(f, "primary_domain_id") ? Number(s(f, "primary_domain_id")) : null, }; let paperId = id; if (id) { const [before] = await sql`SELECT * FROM papers WHERE id = ${id}`; await sql`UPDATE papers SET ${sql(row as never)} WHERE id = ${id}`; await audit(await actor(u), "paper.update", "paper", id, { title: before.title, identifier: before.identifier, status: before.status }, { title: row.title, identifier: row.identifier }); } else { const [r] = await sql`INSERT INTO papers ${sql({ ...row, status: "draft" } as never)} RETURNING id`; paperId = r.id; await audit(await actor(u), "paper.create", "paper", paperId, null, { title: row.title, identifier: row.identifier }); } const pid = paperId!; await sql`DELETE FROM paper_authors WHERE paper_id = ${pid}`; let pos = 1; for (const aid of list(f, "author_ids")) await sql`INSERT INTO paper_authors (paper_id, author_id, position) VALUES (${pid}, ${Number(aid)}, ${pos++})`; await sql`DELETE FROM paper_topics WHERE paper_id = ${pid} AND assigned_by <> 'inferred'`; for (const t of list(f, "topic_ids")) await sql`INSERT INTO paper_topics (paper_id, topic_id, assigned_by) VALUES (${pid}, ${Number(t)}, 'editor') ON CONFLICT DO NOTHING`; if (row.primary_domain_id) await sql`INSERT INTO paper_topics (paper_id, topic_id, assigned_by) VALUES (${pid}, ${row.primary_domain_id}, 'editor') ON CONFLICT DO NOTHING`; await sql`DELETE FROM paper_methods WHERE paper_id = ${pid} AND assigned_by <> 'inferred'`; for (const m of list(f, "method_ids")) await sql`INSERT INTO paper_methods (paper_id, method_id, assigned_by) VALUES (${pid}, ${Number(m)}, 'editor') ON CONFLICT DO NOTHING`; await sql`DELETE FROM paper_datasets WHERE paper_id = ${pid} AND assigned_by <> 'inferred'`; for (const d of list(f, "dataset_ids")) await sql`INSERT INTO paper_datasets (paper_id, dataset_id, assigned_by) VALUES (${pid}, ${Number(d)}, 'editor') ON CONFLICT DO NOTHING`; await lexvecFromMetadata(pid); await enqueue("embed_paper", { paperId: pid }, { dedupe: true }); await enqueue("relations", {}, { dedupe: true, runAfter: new Date(Date.now() + 20_000) }); return { id: pid, slug: idp.slug }; } export async function createPaperAction(f: FormData) { const u = await requireAction(f); const r = await savePaperFields(f, null, u); revalidateAll(r.slug); redirect(`/admin/papers/${r.id}?saved=1`); } export async function updatePaperAction(f: FormData) { const u = await requireAction(f); const id = s(f, "id"); const r = await savePaperFields(f, id, u); revalidateAll(r.slug); redirect(`/admin/papers/${id}?saved=1`); } export async function setStatusAction(f: FormData) { const u = await requireAction(f); const id = s(f, "id"); const status = s(f, "status"); const reason = opt(f, "reason"); if (!["draft", "reserved", "published", "withdrawn", "superseded"].includes(status)) throw new Error("bad status"); const [p] = await sql`SELECT id, slug, status, title, identifier, current_version_id FROM papers WHERE id = ${id}`; if (!p) throw new Error("not found"); if (status === "published" && !p.current_version_id) { const [v] = await sql`SELECT id FROM paper_versions WHERE paper_id = ${id} AND NOT withdrawn ORDER BY version_no DESC LIMIT 1`; if (v) await sql`UPDATE papers SET current_version_id = ${v.id} WHERE id = ${id}`; } await sql`UPDATE papers SET status = ${status}, published_at = CASE WHEN ${status} = 'published' AND published_at IS NULL THEN now() ELSE published_at END, notice = CASE WHEN ${status} IN ('withdrawn', 'superseded') THEN coalesce(${reason}, notice) ELSE notice END WHERE id = ${id}`; await audit(await actor(u), `paper.status.${status}`, "paper", id, { status: p.status }, { status, reason, identifier: p.identifier }); await enqueue("relations", {}, { dedupe: true }); revalidateAll(p.slug); redirect(`/admin/papers/${id}?saved=1`); } export async function deletePaperAction(f: FormData) { const u = await requireAction(f); const id = s(f, "id"); const [p] = await sql`SELECT slug, identifier, status, title FROM papers WHERE id = ${id}`; if (!p) throw new Error("not found"); if (p.status === "published") throw new Error("Published papers cannot be deleted; withdraw them instead."); await sql`DELETE FROM papers WHERE id = ${id}`; await audit(await actor(u), "paper.delete", "paper", id, { identifier: p.identifier, title: p.title, status: p.status }, null); revalidateAll(); redirect("/admin/papers?deleted=1"); } export async function versionAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); const vid = s(f, "version_id"); const [v] = await sql`SELECT v.*, p.slug FROM paper_versions v JOIN papers p ON p.id = v.paper_id WHERE v.id = ${vid}`; if (!v) throw new Error("version not found"); if (op === "withdraw") { await sql`UPDATE paper_versions SET withdrawn = true, withdrawn_reason = ${opt(f, "reason")} WHERE id = ${vid}`; await audit(await actor(u), "version.withdraw", "paper_version", vid, { label: v.label }, { reason: opt(f, "reason") }); } else if (op === "restore") { await sql`UPDATE paper_versions SET withdrawn = false, withdrawn_reason = NULL WHERE id = ${vid}`; await audit(await actor(u), "version.restore", "paper_version", vid, null, { label: v.label }); } else if (op === "make_current") { await sql.begin(async (tx) => { await tx`UPDATE paper_versions SET is_current = false WHERE paper_id = ${v.paper_id}`; await tx`UPDATE paper_versions SET is_current = true WHERE id = ${vid}`; await tx`UPDATE papers SET current_version_id = ${vid}, last_revised_on = coalesce(${v.released_on}, last_revised_on), last_revised_precision = ${v.released_precision} WHERE id = ${v.paper_id}`; }); if (v.file_id) await enqueue("process_pdf", { fileId: v.file_id, paperId: v.paper_id, versionId: vid }, { dedupe: true }); await audit(await actor(u), "version.make_current", "paper_version", vid, null, { label: v.label }); } else if (op === "update") { const d = dateField(f, "released_on"); const pv = parseVersionLabel(s(f, "label")) ?? { label: v.label }; await sql`UPDATE paper_versions SET label = ${pv.label}, released_on = ${d.date}, released_precision = ${d.precision}, description = ${opt(f, "description")}, description_fr = ${opt(f, "description_fr")} WHERE id = ${vid}`; if (v.is_current) await sql`UPDATE papers SET last_revised_on = ${d.date}, last_revised_precision = ${d.precision} WHERE id = ${v.paper_id}`; await audit(await actor(u), "version.update", "paper_version", vid, { label: v.label }, { label: pv.label }); } revalidateAll(v.slug); redirect(`/admin/papers/${v.paper_id}?saved=1#versions`); } export async function relationAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); const from = s(f, "paper_id"); if (op === "add") { const [to] = await sql`SELECT id, identifier FROM papers WHERE slug = ${s(f, "to_slug").toLowerCase()} OR identifier = ${s(f, "to_slug").toUpperCase()}`; if (!to) throw new Error("target paper not found"); const expl = s(f, "explanation") || "Author-curated relationship"; for (const [a, b] of [[from, to.id], [to.id, from]]) await sql`INSERT INTO paper_relations (from_paper_id, to_paper_id, kind, weight, explanation, explanation_fr, inferred) VALUES (${a}, ${b}, 'curated', 1, ${expl}, ${opt(f, "explanation_fr") ?? expl}, false) ON CONFLICT (from_paper_id, to_paper_id, kind) DO UPDATE SET explanation = EXCLUDED.explanation, explanation_fr = EXCLUDED.explanation_fr`; await audit(await actor(u), "relation.add", "paper", from, null, { to: to.identifier, explanation: expl }); } else if (op === "remove") { const [r] = await sql`DELETE FROM paper_relations WHERE id = ${Number(s(f, "relation_id"))} AND kind = 'curated' RETURNING to_paper_id`; if (r) await sql`DELETE FROM paper_relations WHERE from_paper_id = ${r.to_paper_id} AND to_paper_id = ${from} AND kind = 'curated'`; await audit(await actor(u), "relation.remove", "paper", from, null, { relation_id: s(f, "relation_id") }); } const [p] = await sql`SELECT slug FROM papers WHERE id = ${from}`; revalidateAll(p?.slug); redirect(`/admin/papers/${from}?saved=1#relations`); } export async function redirectAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); const back = s(f, "back") || "/admin/papers"; if (op === "add") { const from = s(f, "from_path"); const to = s(f, "to_path"); if (!from.startsWith("/") || !to.startsWith("/")) throw new Error("paths must start with /"); await sql`INSERT INTO redirects (from_path, to_path, status, note) VALUES (${from}, ${to}, ${Number(s(f, "status") || 301)}, ${opt(f, "note")}) ON CONFLICT (from_path) DO UPDATE SET to_path = EXCLUDED.to_path, status = EXCLUDED.status, note = EXCLUDED.note`; await audit(await actor(u), "redirect.add", "redirect", from, null, { to }); } else { await sql`DELETE FROM redirects WHERE from_path = ${s(f, "from_path")}`; await audit(await actor(u), "redirect.remove", "redirect", s(f, "from_path"), null, null); } redirect(back + (back.includes("?") ? "&" : "?") + "saved=1"); } export async function taxonomyAction(f: FormData) { const u = await requireAction(f); const kind = s(f, "kind"); const op = s(f, "op"); const id = s(f, "id") ? Number(s(f, "id")) : null; const slugify = (x: string) => x.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const slug = s(f, "slug") ? slugify(s(f, "slug")) : slugify(s(f, "name_en") || s(f, "display_name")); if (op === "delete") { if (!id) throw new Error("id required"); if (kind === "author") await sql`DELETE FROM authors WHERE id = ${id} AND NOT EXISTS (SELECT 1 FROM paper_authors WHERE author_id = ${id})`; if (kind === "topic") await sql`DELETE FROM topics WHERE id = ${id}`; if (kind === "method") await sql`DELETE FROM methods WHERE id = ${id}`; if (kind === "dataset") await sql`DELETE FROM datasets WHERE id = ${id}`; await audit(await actor(u), `${kind}.delete`, kind, String(id), null, null); redirect("/admin/taxonomy?saved=1"); } if (kind === "author") { const row = { slug, given_name: s(f, "given_name"), family_name: s(f, "family_name"), display_name: s(f, "display_name") || `${s(f, "given_name")} ${s(f, "family_name")}`, orcid: opt(f, "orcid"), email: opt(f, "email"), website: opt(f, "website"), github: opt(f, "github"), google_scholar: opt(f, "google_scholar"), ssrn_url: opt(f, "ssrn_url"), affiliation_en: opt(f, "affiliation_en"), affiliation_fr: opt(f, "affiliation_fr"), department_en: opt(f, "department_en"), department_fr: opt(f, "department_fr"), bio_en: opt(f, "bio_en"), bio_fr: opt(f, "bio_fr"), is_primary: s(f, "is_primary") === "on", sort: Number(s(f, "sort") || 0) }; if (id) await sql`UPDATE authors SET ${sql(row as never)} WHERE id = ${id}`; else await sql`INSERT INTO authors ${sql(row as never)}`; } if (kind === "topic") { const row = { slug, name_en: s(f, "name_en"), name_fr: s(f, "name_fr") || s(f, "name_en"), description_en: opt(f, "description_en"), description_fr: opt(f, "description_fr"), kind: s(f, "topic_kind") === "domain" ? "domain" : "topic", accent: opt(f, "accent"), sort: Number(s(f, "sort") || 0), is_active: s(f, "is_active") !== "off" }; if (id) await sql`UPDATE topics SET ${sql(row as never)} WHERE id = ${id}`; else await sql`INSERT INTO topics ${sql(row as never)}`; } if (kind === "method") { const row = { slug, name_en: s(f, "name_en"), name_fr: s(f, "name_fr") || s(f, "name_en"), description_en: opt(f, "description_en"), description_fr: opt(f, "description_fr"), category: s(f, "category") || "general", sort: Number(s(f, "sort") || 0) }; if (id) await sql`UPDATE methods SET ${sql(row as never)} WHERE id = ${id}`; else await sql`INSERT INTO methods ${sql(row as never)}`; } if (kind === "dataset") { const row = { slug, name_en: s(f, "name_en"), name_fr: s(f, "name_fr") || s(f, "name_en"), provider: opt(f, "provider"), coverage: opt(f, "coverage"), frequency: opt(f, "frequency"), access_conditions: opt(f, "access_conditions"), license: opt(f, "license"), url: opt(f, "url"), description_en: opt(f, "description_en"), description_fr: opt(f, "description_fr"), is_public: s(f, "is_public") === "on", sort: Number(s(f, "sort") || 0) }; if (id) await sql`UPDATE datasets SET ${sql(row as never)} WHERE id = ${id}`; else await sql`INSERT INTO datasets ${sql(row as never)}`; } if (kind === "series") { const row = { slug, code: s(f, "code").toUpperCase(), name_en: s(f, "name_en"), name_fr: s(f, "name_fr") || s(f, "name_en"), description_en: opt(f, "description_en"), description_fr: opt(f, "description_fr"), issn: opt(f, "issn"), sort: Number(s(f, "sort") || 0), is_active: s(f, "is_active") !== "off" }; if (id) await sql`UPDATE series SET ${sql(row as never)} WHERE id = ${id}`; else await sql`INSERT INTO series ${sql(row as never)}`; } await audit(await actor(u), `${kind}.${id ? "update" : "create"}`, kind, id ? String(id) : slug, null, { slug }); await enqueue("relations", {}, { dedupe: true }); revalidateAll(); redirect("/admin/taxonomy?saved=1"); } export async function settingsAction(f: FormData) { const u = await requireAction(f); const values: Record = { "site.attribution": { en: s(f, "attribution_en"), fr: s(f, "attribution_fr") }, "site.institution": { name_en: s(f, "inst_name_en"), name_fr: s(f, "inst_name_fr"), department_en: opt(f, "inst_dept_en"), department_fr: opt(f, "inst_dept_fr"), url: opt(f, "inst_url"), logo_authorized: s(f, "logo_authorized") === "on" }, "site.contact": { email: opt(f, "contact_email"), website: opt(f, "contact_website") }, "home.featured_limit": Number(s(f, "featured_limit") || 4), "ingest.extra_dirs": lines(f, "ingest_dirs"), }; for (const [k, v] of Object.entries(values)) await sql`INSERT INTO settings (key, value) VALUES (${k}, ${sql.json(v as never)}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`; await audit(await actor(u), "settings.update", "settings", null, null, { keys: Object.keys(values) }); revalidateAll(); redirect("/admin/settings?saved=1"); } export async function ingestAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); if (op === "scan") { await enqueue("ingest_scan", {}, { dedupe: true }); await audit(await actor(u), "ingest.scan", "ingest", null, null, null); redirect("/admin/ingest?queued=1"); } const cid = s(f, "candidate_id"); const [c] = await sql`SELECT * FROM ingest_candidates WHERE id = ${cid}`; if (!c) throw new Error("candidate not found"); if (op === "reject") { await sql`UPDATE ingest_candidates SET status = 'rejected', reviewed_by = ${u.id}, reviewed_at = now(), review_note = ${opt(f, "note")} WHERE id = ${cid}`; await audit(await actor(u), "ingest.reject", "ingest_candidate", cid, null, { note: opt(f, "note") }); redirect("/admin/ingest?saved=1"); } if (op === "reprocess") { await sql`UPDATE ingest_candidates SET status = 'queued' WHERE id = ${cid}`; await enqueue("ingest_candidate", { candidateId: cid }); redirect(`/admin/ingest/${cid}?queued=1`); } const d = dateField(f, "released_on"); const ver = parseVersionLabel(s(f, "version_label") || "v1") ?? { versionNo: 1, label: "v1" }; if (op === "attach") { const [p] = await sql`SELECT id, slug, identifier FROM papers WHERE id = ${s(f, "target_paper_id")}`; if (!p) throw new Error("target paper not found"); const [{ next }] = await sql`SELECT coalesce(max(version_no), 0) + 1 AS next FROM paper_versions WHERE paper_id = ${p.id}`; const [v] = await sql`INSERT INTO paper_versions (paper_id, version_no, label, released_on, released_precision, description, file_id, sha256, is_current) VALUES (${p.id}, ${next}, ${ver.label}, ${d.date}, ${d.precision}, ${opt(f, "description")}, ${c.file_id}, ${c.sha256}, ${s(f, "make_current") === "on"}) RETURNING id`; if (s(f, "make_current") === "on") { await sql`UPDATE paper_versions SET is_current = false WHERE paper_id = ${p.id} AND id <> ${v.id}`; await sql`UPDATE papers SET current_version_id = ${v.id}, last_revised_on = ${d.date}, last_revised_precision = ${d.precision} WHERE id = ${p.id}`; await enqueue("process_pdf", { fileId: c.file_id, paperId: p.id, versionId: v.id }); } else await enqueue("process_pdf", { fileId: c.file_id, versionId: v.id }); await sql`UPDATE ingest_candidates SET status = 'merged', paper_id = ${p.id}, reviewed_by = ${u.id}, reviewed_at = now() WHERE id = ${cid}`; await audit(await actor(u), "ingest.attach", "ingest_candidate", cid, null, { paper: p.identifier, version: ver.label }); revalidateAll(p.slug); redirect(`/admin/papers/${p.id}?saved=1#versions`); } if (op === "approve") { const [series] = await sql`SELECT id, code FROM series WHERE code = ${s(f, "series_code").toUpperCase()}`; if (!series) throw new Error("Unknown series"); const number = Number(s(f, "number")); if (!number) throw new Error("number required"); const idp = makeIdentifier(series.code, number); const [dup] = await sql`SELECT id FROM papers WHERE slug = ${idp.slug}`; if (dup) throw new Error(`${idp.identifier} already exists — use "attach as version" instead`); const prop = c.proposal as Record; const [p] = await sql`INSERT INTO papers (series_id, number, identifier, slug, title, abstract, keywords, jel, year, first_released_on, first_released_precision, last_revised_on, last_revised_precision, status, language) VALUES (${series.id}, ${number}, ${idp.identifier}, ${idp.slug}, ${s(f, "title") || String(prop.title ?? "Untitled")}, ${sql.json(s(f, "abstract") ? [s(f, "abstract")] : prop.abstract ? [String(prop.abstract)] : [])}, ${lines(f, "keywords")}, ${s(f, "jel").split(/[,\s]+/).filter(Boolean)}, ${s(f, "year") ? Number(s(f, "year")) : null}, ${d.date}, ${d.precision}, ${d.date}, ${d.precision}, 'draft', ${s(f, "language") || "en"}) RETURNING id`; let pos = 1; for (const aid of list(f, "author_ids")) await sql`INSERT INTO paper_authors (paper_id, author_id, position) VALUES (${p.id}, ${Number(aid)}, ${pos++})`; const [v] = await sql`INSERT INTO paper_versions (paper_id, version_no, label, released_on, released_precision, description, file_id, sha256, is_current) VALUES (${p.id}, 1, ${ver.label}, ${d.date}, ${d.precision}, ${opt(f, "description") ?? "Initial release"}, ${c.file_id}, ${c.sha256}, true) RETURNING id`; await sql`UPDATE papers SET current_version_id = ${v.id} WHERE id = ${p.id}`; await sql`UPDATE ingest_candidates SET status = 'approved', paper_id = ${p.id}, reviewed_by = ${u.id}, reviewed_at = now() WHERE id = ${cid}`; await enqueue("process_pdf", { fileId: c.file_id, paperId: p.id, versionId: v.id }); await lexvecFromMetadata(p.id); await audit(await actor(u), "ingest.approve", "ingest_candidate", cid, null, { paper: idp.identifier, note: "created as draft; publish explicitly" }); redirect(`/admin/papers/${p.id}?saved=1`); } throw new Error("unknown op"); } export async function systemAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); if (op === "backup") await enqueue("backup", {}, { dedupe: true }); if (op === "relations") await enqueue("relations", {}, { dedupe: true }); if (op === "stats") await enqueue("daily_stats", {}, { dedupe: true }); if (op === "embed_all") { const rows = await sql`SELECT id FROM papers WHERE status = 'published'`; for (const r of rows) await enqueue("embed_paper", { paperId: r.id }, { dedupe: true }); } if (op === "retry") await sql`UPDATE jobs SET status = 'queued', run_after = now(), attempts = 0 WHERE status = 'failed'`; await audit(await actor(u), `system.${op}`, "system", null, null, null); redirect("/admin/system?queued=1"); } export async function accountAction(f: FormData) { const u = await requireAction(f); const op = s(f, "op"); const [me] = await sql`SELECT * FROM users WHERE id = ${u.id}`; if (op === "password") { const cur = s(f, "current"), nw = s(f, "new"), conf = s(f, "confirm"); if (!(await verifyPassword(me.password_hash, cur))) throw new Error("Current password is wrong"); if (nw !== conf) throw new Error("Passwords do not match"); const pol = passwordPolicy(nw); if (pol) throw new Error(pol); await sql`UPDATE users SET password_hash = ${await hashPassword(nw)}, must_change_password = false WHERE id = ${u.id}`; await revokeOtherSessions(u.id, u.sessionId); await audit(await actor(u), "user.password_change", "user", u.id, null, null); redirect("/admin/account?saved=1"); } if (op === "totp_enable") { const secret = s(f, "secret"); const code = s(f, "code"); if (!verifySync({ token: code, secret }).valid) throw new Error("Invalid code"); await sql`UPDATE users SET totp_secret = ${secret}, totp_enabled = true WHERE id = ${u.id}`; await sql`UPDATE sessions SET mfa_passed = true WHERE id = ${u.sessionId}`; await audit(await actor(u), "user.totp_enable", "user", u.id, null, null); redirect("/admin/account?saved=1"); } if (op === "totp_disable") { if (!(await verifyPassword(me.password_hash, s(f, "current")))) throw new Error("Current password is wrong"); await sql`UPDATE users SET totp_secret = NULL, totp_enabled = false WHERE id = ${u.id}`; await audit(await actor(u), "user.totp_disable", "user", u.id, null, null); redirect("/admin/account?saved=1"); } if (op === "revoke_others") { await revokeOtherSessions(u.id, u.sessionId); redirect("/admin/account?saved=1"); } if (op === "logout") { await revokeSession(u.sessionId); await clearSessionCookie(); await audit(await actor(u), "user.logout", "user", u.id, null, null); redirect("/login"); } throw new Error("unknown op"); }