spb/note-spboucher-ai
Public
1/**2 * POST multipart { file, kind?, parentId? } → small files are imported inline ({ page, result }); large ones are queued ({ jobId }).3 * GET ?jobId= → progress record (settings key import.<jobId>).4 */5import fs from "node:fs/promises";6import path from "node:path";7import { randomUUID } from "node:crypto";8import { handler, HttpError, uuid } from "@/lib/api";9import { enqueue, QUEUES } from "@/lib/jobs";10import { audit } from "@/lib/audit";11import { DATA_DIR } from "@/lib/files/storage";12import { detectKind, runImport, ImportError, INLINE_MAX, KINDS, setImportProgress, getImportProgress, type ImportKind } from "@/lib/import";13import { getPage } from "@/lib/pages/service";14const MAX = 500 * 1024 * 1024;15const INLINE_KINDS = new Set<ImportKind>(["markdown", "csv", "bibtex", "docx", "pdf"]);1617export const POST = handler(async ({ req, ws, user }) => {18 const form = await req.formData(); const file = form.get("file");19 if (!(file instanceof File)) throw new HttpError(400, "file missing");20 if (file.size > MAX) throw new HttpError(413, "File too large (max 500 MB)");21 const parentId = (form.get("parentId") as string) || null; if (parentId) { uuid.parse(parentId); await getPage(ws.id, parentId); }22 let kind: ImportKind;23 try { const k = (form.get("kind") as string) || ""; kind = KINDS.includes(k as ImportKind) ? (k as ImportKind) : detectKind(file.name, file.type); } catch (e) { throw new HttpError(e instanceof ImportError ? e.status : 400, (e as Error).message); }24 const buffer = Buffer.from(await file.arrayBuffer());25 const sync = form.get("sync") === "1";26 if ((INLINE_KINDS.has(kind) && buffer.length <= INLINE_MAX) || sync) {27 try {28 const result = await runImport({ workspaceId: ws.id, kind, buffer, filename: file.name, parentId });29 await audit(user.id, "import", result.pageId ? { type: "page", id: result.pageId } : undefined, { kind, filename: file.name, size: file.size, stats: result.stats });30 return Response.json({ done: true, ...result }, { status: 201 });31 } catch (e) { if (e instanceof ImportError) throw new HttpError(e.status, e.message); throw e; }32 }33 // async: persist to DATA_DIR/imports and hand over to the worker34 const jobId = randomUUID();35 const dir = path.join(DATA_DIR, "imports"); await fs.mkdir(dir, { recursive: true });36 const tmp = path.join(dir, `${jobId}-${file.name.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120)}`);37 await fs.writeFile(tmp, buffer);38 await setImportProgress(jobId, { status: "queued", progress: 0, kind, filename: file.name, workspaceId: ws.id, message: "Queued" });39 const sent = await enqueue(QUEUES.importFile, { jobId, workspaceId: ws.id, path: tmp, kind, parentId, filename: file.name }, { retryLimit: 0, expireInSeconds: 3 * 3600 });40 if (!sent) { await fs.unlink(tmp).catch(() => {}); await setImportProgress(jobId, { status: "error", error: "Job queue unavailable" }); throw new HttpError(503, "Job queue unavailable — retry later or import a smaller file"); }41 await audit(user.id, "import.queued", undefined, { jobId, kind, filename: file.name, size: file.size });42 return Response.json({ jobId, kind }, { status: 202 });43});4445export const GET = handler(async ({ req, ws }) => {46 const jobId = new URL(req.url).searchParams.get("jobId"); if (!jobId) throw new HttpError(400, "jobId required");47 const p = await getImportProgress(uuid.parse(jobId)); if (!p || (p.workspaceId && p.workspaceId !== ws.id)) throw new HttpError(404, "Unknown job");48 return Response.json({ jobId, ...p });49});50