SPB Git
3.4 KB · 31 lines typescript
1/** Bulk import: JSON (array of records or single record) or CSV with the flat columns documented in docs/data-model.md. Validation errors are returned per row; nothing is written if any row fails unless ?partial=1. */2import Papa from "papaparse";3import { headers } from "next/headers";4import { requireUser, jsonError, HttpError } from "@/lib/auth/guard";5import { clientIp } from "@/lib/auth/session";6import { toolRecordSchema } from "@/lib/content/schema";7import { saveToolRecord, getToolRecord } from "@/lib/admin/tools";8import { sql } from "@/db";9const LIST = ["aliases", "capabilities", "workflows", "advantages", "limitations", "prerequisites", "audiences", "alternatives", "tags", "use_cases", "operating_systems", "execution_modes", "interfaces", "deployment_models", "integrations", "data_formats", "languages", "pricing_model"];10function fromCsvRow(r: Record<string, string>) {11  const o: any = {}; for (const [k, v] of Object.entries(r)) { if (v === "" || v == null) continue; if (LIST.includes(k)) o[k] = v.split("|").map((x) => x.trim()).filter(Boolean); else if (k === "categories") o[k] = v.split("|").map((x, i) => ({ slug: x.trim(), primary: i === 0 })); else if (k === "sources") o[k] = v.split("|").map((x) => { const [url, type] = x.split("^"); return { url: url.trim(), type: (type ?? "official_site").trim(), checked_at: new Date().toISOString().slice(0, 10) }; }); else if (k === "vendor") o.vendor = { name: v }; else if (k === "self_hostable" || k === "featured") o[k] = v === "true"; else o[k] = v; }12  return o;13}14export async function POST(req: Request) {15  try {16    const s = await requireUser(req); const ip = clientIp(await headers());17    const partial = new URL(req.url).searchParams.get("partial") === "1"; const publish = new URL(req.url).searchParams.get("publish") === "1";18    const ct = req.headers.get("content-type") ?? ""; const text = await req.text(); if (text.length > 20_000_000) throw new HttpError(413, "Too large");19    let rows: unknown[];20    if (ct.includes("csv") || text.trimStart().startsWith("slug,") || (!text.trimStart().startsWith("[") && !text.trimStart().startsWith("{"))) { const parsed = Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true }); rows = parsed.data.map(fromCsvRow); }21    else { const j = JSON.parse(text); rows = Array.isArray(j) ? j : [j]; }22    const results: { index: number; slug?: string; ok: boolean; error?: string }[] = []; const valid: any[] = [];23    rows.forEach((r, i) => { const p = toolRecordSchema.safeParse(r); if (p.success) valid.push(p.data); else results.push({ index: i, slug: (r as any)?.slug, ok: false, error: p.error.issues.map((x) => `${x.path.join(".")}: ${x.message}`).join("; ") }); });24    if (results.length && !partial) return Response.json({ ok: false, written: 0, results }, { status: 400 });25    let written = 0;26    for (const r of valid) { try { const existing = await getToolRecord(r.slug); const status = publish ? "published" : r.editorial_status; await saveToolRecord({ ...r, editorial_status: status }, s, ip, existing?.id); written++; results.push({ index: -1, slug: r.slug, ok: true }); } catch (e) { results.push({ index: -1, slug: r.slug, ok: false, error: (e as Error).message }); } }27    await sql`INSERT INTO audit_log (actor_id, actor, action, details, ip) VALUES (${s.id}, ${s.email}, 'import.bulk', ${sql.json({ rows: rows.length, written })}, ${ip})`;28    return Response.json({ ok: true, written, results });29  } catch (e) { return jsonError(e); }30}31
Files · main
Menu

Theme