import { NextResponse } from "next/server"; import { createHash } from "node:crypto"; import { pbAdmin } from "@/lib/pb"; import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp", "application/pdf"] as const; const MAX_FILE_BYTES = 20 * 1024 * 1024; const MAX_FILES_PER_REQUEST = 50; type ValidatedFile = { file: File; hash: string; ordinal: number }; type Outcome = | { ok: true; batch_id: string; projectCode: string; status: string; created: string; filename: string } | { ok: true; duplicate: true; existing_batch_id: string; filename: string } | { ok: false; error: string; filename: string }; function formValues(form: FormData, key: string): string[] { return form.getAll(key).filter((value): value is string => typeof value === "string"); } function valueFor(values: string[], index: number): string | undefined { const value = values.length === 1 ? values[0] : values[index]; return value?.trim() || undefined; } function numberFor(values: string[], index: number): number | undefined { const value = valueFor(values, index); if (!value) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } /** One request creates at most one batch; each accepted file is immutable source data. */ export async function POST(request: Request) { const user = await requireApiUser("financial", { allowClock: true }); if (user instanceof Response) return user; const start = Date.now(); let form: FormData; try { form = await request.formData(); } catch { return NextResponse.json({ error: "invalid_form_data" }, { status: 400 }); } const rawFiles = form.getAll("file").filter((value): value is File => value instanceof File); const siteId = (form.get("site") as string | null)?.trim(); if (rawFiles.length === 0) return NextResponse.json({ error: "missing_file" }, { status: 400 }); if (rawFiles.length > MAX_FILES_PER_REQUEST) return NextResponse.json({ error: "too_many_files", max_files: MAX_FILES_PER_REQUEST, got: rawFiles.length }, { status: 400 }); if (!siteId) return NextResponse.json({ error: "missing_site" }, { status: 400 }); const denied = requireSiteAccess(user, siteId); if (denied) return denied; const sourceValues = formValues(form, "source"); const captureSessionValues = formValues(form, "capture_session_id"); const deliverySessionValues = formValues(form, "delivery_session_id"); const capturedAtValues = formValues(form, "captured_at"); const latitudeValues = formValues(form, "latitude"); const longitudeValues = formValues(form, "longitude"); const accuracyValues = formValues(form, "location_accuracy_m"); const validated: ValidatedFile[] = []; const results: Outcome[] = []; for (const [index, file] of rawFiles.entries()) { let error: string | undefined; if (file.size === 0) error = "empty_file"; else if (file.size > MAX_FILE_BYTES) error = "file_too_large"; else if (!ALLOWED_MIMES.includes(file.type as (typeof ALLOWED_MIMES)[number])) error = "unsupported_type"; if (error) { results[index] = { ok: false, error, filename: file.name }; continue; } try { const bytes = Buffer.from(await file.arrayBuffer()); validated.push({ file, hash: createHash("sha256").update(bytes).digest("hex"), ordinal: index }); } catch (err) { console.error("[dn-upload] read_failed", { filename: file.name, err: String(err) }); results[index] = { ok: false, error: "read_failed", filename: file.name }; } } // Preserve the original single-file contract (including detailed // validation payloads) while allowing mixed multi-file requests to proceed. if (rawFiles.length === 1 && results[0] && !results[0].ok) { const file = rawFiles[0]; if (file.size === 0) return NextResponse.json({ error: "empty_file", filename: file.name }, { status: 400 }); if (file.size > MAX_FILE_BYTES) return NextResponse.json({ error: "file_too_large", filename: file.name, max_bytes: MAX_FILE_BYTES, got: file.size }, { status: 400 }); return NextResponse.json({ error: "unsupported_type", filename: file.name, got: file.type, allowed: [...ALLOWED_MIMES] }, { status: 400 }); } const pb = await pbAdmin(); let resolvedProject = ""; try { const site = await pb.collection("sites").getOne(siteId, { fields: "project_code" }); resolvedProject = site.project_code ?? ""; } catch { return NextResponse.json({ error: "Site not found" }, { status: 400 }); } const batches = pb.collection("dn_batches"); const sources = pb.collection("dn_batch_sources"); const requestCaptureSession = valueFor(captureSessionValues, 0); let batch: { id: string; created?: string } | null = null; let reusedBatch = false; if (requestCaptureSession) { const existing = await batches.getFullList({ filter: pb.filter("capture_session_id = {:captureSession} && site = {:site}", { captureSession: requestCaptureSession, site: siteId }), fields: "id,created,source_count,status" }); if (existing.length) batch = existing[0]; if (!batch) { const existingSource = await sources.getFullList({ filter: pb.filter("capture_session_id = {:captureSession} && batch.site = {:site}", { captureSession: requestCaptureSession, site: siteId }), fields: "batch" }); if (existingSource.length) batch = await batches.getOne(existingSource[0].batch, { fields: "id,created,source_count,status" }); } reusedBatch = Boolean(batch); } const sourceName = valueFor(sourceValues, 0); const pending: ValidatedFile[] = []; const seenHashes = new Map(); const deferredDuplicates: Array<{ ordinal: number; hash: string }> = []; for (const entry of validated) { if (seenHashes.has(entry.hash)) { const existingBatchId = seenHashes.get(entry.hash); if (existingBatchId) { results[entry.ordinal] = { ok: true, duplicate: true, existing_batch_id: existingBatchId, filename: entry.file.name }; } else { results[entry.ordinal] = { ok: true, duplicate: true, existing_batch_id: "", filename: entry.file.name }; deferredDuplicates.push({ ordinal: entry.ordinal, hash: entry.hash }); } continue; } try { const existing = await sources.getFullList({ filter: pb.filter("file_hash = {:hash} && batch.site = {:site}", { hash: entry.hash, site: siteId }), fields: "id,batch", }); if (existing.length) { const existingBatchId = existing[0].batch ?? ""; seenHashes.set(entry.hash, existingBatchId); results[entry.ordinal] = { ok: true, duplicate: true, existing_batch_id: existingBatchId, filename: entry.file.name }; continue; } seenHashes.set(entry.hash, null); pending.push(entry); } catch (err) { results[entry.ordinal] = { ok: false, error: err instanceof Error ? err.message : "Failed to check source", filename: entry.file.name }; } } if (!batch && pending.length > 0) { batch = await batches.create({ file: pending[0].file, file_hash: pending[0].hash, site: siteId, source: sourceName === "mobile" || sourceName === "email" ? sourceName : "web", uploaded_by: "web-upload", status: "needs_attention", stage: "preparing", source_count: 0, capture_session_id: requestCaptureSession ?? "", uploaded_at: new Date().toISOString(), page_count: 0, notes_detected: 0, notes_promoted: 0, attempts: 0, }); } let created = 0; let duplicates = results.filter((result) => result && result.ok && "duplicate" in result).length; let errors = results.filter(Boolean).filter((result) => !result.ok).length; const failedHashes = new Map(); for (const { file, hash, ordinal } of pending) { try { if (reusedBatch && batch) { results[ordinal] = { ok: true, duplicate: true, existing_batch_id: batch.id, filename: file.name }; duplicates++; continue; } if (!batch) throw new Error("Unable to create upload batch"); const metadata: Record = { batch: batch.id, file, file_hash: hash, ordinal, mime: file.type, original_name: file.name }; const captureSession = valueFor(captureSessionValues, ordinal); const deliverySession = valueFor(deliverySessionValues, ordinal); const capturedAt = valueFor(capturedAtValues, ordinal); if (captureSession) metadata.capture_session_id = captureSession; if (deliverySession) metadata.delivery_session_id = deliverySession; if (capturedAt) metadata.captured_at = capturedAt; const latitude = numberFor(latitudeValues, ordinal); const longitude = numberFor(longitudeValues, ordinal); const accuracy = numberFor(accuracyValues, ordinal); if (latitude !== undefined) metadata.latitude = latitude; if (longitude !== undefined) metadata.longitude = longitude; if (accuracy !== undefined) metadata.location_accuracy_m = accuracy; await sources.create(metadata); results[ordinal] = { ok: true, batch_id: batch.id, projectCode: resolvedProject, status: "inbox", created: batch.created ?? "", filename: file.name }; created++; } catch (err) { console.warn("[dn-upload] per_source_failed", { filename: file.name, err: String(err) }); const message = err instanceof Error ? err.message : "Failed to create source"; failedHashes.set(hash, message); results[ordinal] = { ok: false, error: message, filename: file.name }; errors++; } } for (const { ordinal, hash } of deferredDuplicates) { const duplicate = results[ordinal]; if (!duplicate || !duplicate.ok || !("duplicate" in duplicate)) continue; const failed = failedHashes.get(hash); if (failed) { results[ordinal] = { ok: false, error: failed, filename: duplicate.filename }; errors++; } else { results[ordinal] = { ...duplicate, existing_batch_id: batch?.id ?? "" }; } } let persistenceError: string | null = null; if (batch && !reusedBatch) { if (created > 0) { try { await batches.update(batch.id, { source_count: created, status: "inbox", stage: "queued" }); } catch (err) { const updateError = err instanceof Error ? err.message : String(err); persistenceError = `source_count_update_failed: ${updateError}`; try { await batches.delete(batch.id); } catch (cleanupErr) { const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); persistenceError += `; batch_cleanup_failed: ${cleanupMessage}`; console.error("[dn-upload] batch_cleanup_failed", { batch_id: batch.id, updateError, cleanupError: cleanupMessage }); } batch = null; } } else { await batches.delete(batch.id); batch = null; } } duplicates = results.filter((result) => result && result.ok && "duplicate" in result).length; const orderedResults = results.filter(Boolean); console.log("[dn-upload] batch_done", { total: rawFiles.length, created, duplicates, errors, duration_ms: Date.now() - start }); if (persistenceError) { return NextResponse.json({ error: persistenceError, batch_id: "", source_count: 0, status: "error", results: orderedResults, summary: { total: rawFiles.length, created: 0, duplicates, errors: errors + 1 } }, { status: 500 }); } if (rawFiles.length === 1) { const result = orderedResults[0]; if (result.ok && "duplicate" in result) return NextResponse.json({ duplicate: true, status: "duplicate", existing_batch_id: result.existing_batch_id, filename: result.filename }, { status: 200 }); if (result.ok) return NextResponse.json({ batch_id: result.batch_id, siteId, projectCode: result.projectCode, status: result.status, created: result.created }, { status: 201 }); return NextResponse.json({ error: result.error, filename: result.filename }, { status: 500 }); } return NextResponse.json({ batch_id: batch?.id ?? "", source_count: created, status: batch ? "inbox" : "error", results: orderedResults, summary: { total: rawFiles.length, created, duplicates, errors } }, { status: 201 }); }