MEGAINFRA-WEBAPP/src/app/api/purchase-orders/upload/route.ts

188 lines
6.4 KiB
TypeScript

import { NextResponse } from "next/server";
import { createHash } from "node:crypto";
import { pbAdmin } from "@/lib/pb";
import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
import { placeholderPoNumber } from "@/lib/po-placeholder";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/purchase-orders/upload
*
* Accepts multipart/form-data with:
* file - the PO document, PDF or image (required)
* site - site id (required) - the uploader knows which site this PO
* belongs to; OCR-derived site matching only succeeds on a small
* fraction of real documents, so we no longer wait for it.
*
* The purchase_orders row is now born here, before OCR has run — not in
* the old shared data_import_batches pool. purchase_order_number carries
* a UNIQUE index (idx_po_number), so an unprocessed row can't hold ""
* (two of them would collide). Instead it gets a placeholder that is
* unique by construction (see src/lib/po-placeholder.ts); n8n's
* extraction overwrites it once OCR completes, via
* /api/purchase-orders/process-batch.
*
* Hash dedup: before creating a new row, we SHA-256 the bytes and look
* for a same-site `purchase_orders` row with the same hash that hasn't
* failed. If we find one, we short-circuit with 200 and
* `{ duplicate: true, existing_po_id, ... }` — no new row, no wasted AI
* tokens.
*
* Note: this PocketBase instance has no usable `created`/`updated`
* autodate field on purchase_orders (filtering or sorting by `created`
* 400s here, same as delivery_notes and other collections in this
* project). Unlike delivery_notes, purchase_orders had no other
* timestamp field either — so `date_uploaded` is stamped at create time
* for auditability.
*/
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 file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing_file" }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "empty_file" }, { status: 400 });
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: "file_too_large", max_bytes: MAX_FILE_BYTES },
{ status: 400 },
);
}
if (!ALLOWED_MIME.has(file.type)) {
return NextResponse.json(
{ error: "unsupported_type", got: file.type, allowed: [...ALLOWED_MIME] },
{ status: 400 },
);
}
const siteId = (form.get("site") as string | null)?.trim();
if (!siteId) {
return NextResponse.json({ error: "missing_site" }, { status: 400 });
}
const denied = requireSiteAccess(user, siteId);
if (denied) return denied;
// Hash the file bytes. Cheap enough to do before the PB round-trip
// (20 MB worst case, but in practice POs are a few hundred KB to ~2 MB).
let file_hash: string;
try {
const bytes = Buffer.from(await file.arrayBuffer());
file_hash = createHash("sha256").update(bytes).digest("hex");
} catch (err) {
console.error("[po-upload] hash_failed", { err: String(err) });
return NextResponse.json(
{ error: "hash_failed" },
{ status: 500 },
);
}
const pb = await pbAdmin();
// Hash dedup against purchase_orders itself — there is no separate
// batch pool anymore, the PO row owns the hash. Excludes rows whose
// last processing attempt failed, so a bad prior attempt doesn't block
// a legitimate re-upload. Identical successful files remain duplicates
// regardless of age; number-only matches are handled during extraction.
let existing: Array<{ id: string; purchase_order_number?: string }> = [];
try {
existing = await pb.collection("purchase_orders").getFullList({
filter: pb.filter(
'file_hash = {:hash} && site = {:site} && processing_status != "failed"',
{ hash: file_hash, site: siteId },
),
fields: "id,purchase_order_number",
});
} catch (err) {
// Dedup query failing is non-fatal — log and proceed to create the
// row. Worst case is we miss the dedup and end up with two rows for
// the same file.
console.warn("[po-upload] dedup_query_failed", { err: String(err) });
}
if (existing.length > 0) {
const prior = existing[0];
console.log("[po-upload] duplicate_by_hash", {
file_hash,
existing_po_id: prior.id,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{
duplicate: true,
status: "duplicate",
existing_po_id: prior.id,
filename: file.name,
},
{ status: 200 },
);
}
let po: { id: string };
try {
po = await pb.collection("purchase_orders").create({
// Unique by construction — the real number arrives with the OCR
// result. See src/lib/po-placeholder.ts for why "" won't do.
purchase_order_number: placeholderPoNumber(),
site: siteId,
purchase_order_file: file,
file_hash,
date_uploaded: new Date().toISOString(),
processing_status: "pending",
attempts: 0,
is_verified: false,
raw_ocr_data: { source: "upload", fileName: file.name },
});
} catch (err) {
// PocketBase's ClientResponseError carries the per-field validation
// details on err.response.data — surface them so the user can see
// exactly what went wrong (e.g. mime type not allowed, missing field).
const e = err as {
status?: number;
response?: { code?: number; data?: unknown; message?: string };
};
console.error("[po-upload] create failed", {
err: String(err),
status: e?.status,
response: e?.response,
});
return NextResponse.json(
{
error: "po_create_failed",
pb_status: e?.status ?? null,
pb_message: e?.response?.message ?? null,
pb_data: e?.response?.data ?? null,
},
{ status: 500 },
);
}
console.log("[po-upload] queued", {
purchase_order_id: po.id,
filename: file.name,
size: file.size,
contentType: file.type,
file_hash,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{ purchase_order_id: po.id, status: "queued", filename: file.name },
{ status: 202 },
);
}