import { desc, eq } from "drizzle-orm"; import { Hono } from "hono"; import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "../db"; import { labDrafts } from "../db/schema"; import { extractFromText } from "../lib/extract"; import { pdfToText } from "../lib/pdf"; export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; const MAX_UPLOAD = 15 * 1024 * 1024; export function labsRoutes(deps: LabsDeps) { const app = new Hono(); app.post("/labs/upload", async (c) => { const body = await c.req.parseBody(); const file = body.file; if (!(file instanceof File)) return c.json({ error: "file field required" }, 400); if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") { return c.json({ error: "not_a_pdf" }, 400); } if (file.size > MAX_UPLOAD) return c.json({ error: "too_large" }, 400); const id = randomUUID(); const uploadsDir = join(deps.dataDir, "uploads"); mkdirSync(uploadsDir, { recursive: true }); const filePath = join(uploadsDir, `${id}.pdf`); const bytes = new Uint8Array(await file.arrayBuffer()); await Bun.write(filePath, bytes); // Extraction failures land on the draft row so the user sees them in the // review UI instead of the upload 500ing. let extracted: string | null = null; let error: string | null = null; try { const text = await pdfToText(bytes); if (text.trim().length < 20) throw new Error("pdf_error: no text layer (scanned PDFs are not supported yet)"); const draft = await extractFromText({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch }, text); extracted = JSON.stringify(draft); } catch (e) { error = e instanceof Error ? e.message : "extraction failed"; } await deps.db.insert(labDrafts).values({ id, filename: file.name, filePath, status: "pending", extracted, error, createdAt: Date.now(), }); return c.json({ id }, 201); }); app.get("/labs/drafts", async (c) => { const rows = await deps.db.select().from(labDrafts).orderBy(desc(labDrafts.createdAt)); return c.json({ drafts: rows.map((r) => ({ id: r.id, filename: r.filename, status: r.status, error: r.error, markerCount: r.extracted ? (JSON.parse(r.extracted).markers?.length ?? 0) : 0, createdAt: r.createdAt, })), }); }); return app; }