188 lines
6.4 KiB
TypeScript
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 },
|
|
);
|
|
}
|