224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
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<string, string | null>();
|
|
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<string, string>();
|
|
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<string, unknown> = { 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 });
|
|
}
|