diff --git a/src/app/api/delivery-notes/upload/route.test.ts b/src/app/api/delivery-notes/upload/route.test.ts new file mode 100644 index 0000000..2e7c0fd --- /dev/null +++ b/src/app/api/delivery-notes/upload/route.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirror of src/app/api/purchase-orders/upload/route.test.ts, adapted +// for the delivery-notes path. Same pbAdmin mock surface (getFullList +// for the dedup query, create for the new DN + batch rows, plus getOne +// for the site lookup). +const getFullListMock = vi.fn(); +const createMock = vi.fn(); +const getOneMock = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name === "data_import_batches") { + return { create: createMock, getFullList: getFullListMock }; + } + if (name === "delivery_notes") { + return { create: createMock }; + } + if (name === "sites") { + return { getOne: getOneMock }; + } + throw new Error(`unexpected collection in test: ${name}`); + }, + })), +})); + +import { POST } from "./route"; + +function makeRequest(form: FormData): Request { + return new Request("http://x/api/delivery-notes/upload", { + method: "POST", + body: form, + }); +} + +function slip( + bytes = 128, + type = "image/jpeg", + name = "dn-5678.jpg", +): File { + return new File([new Uint8Array(bytes)], name, { type }); +} + +const DEDUP_WINDOW_DAYS = 30; + +function daysAgoIso(days: number): string { + return new Date(Date.now() - days * 86400_000).toISOString(); +} + +function makeValidForm(file?: File, site = "siteabc"): FormData { + const form = new FormData(); + form.append("file", file ?? slip()); + form.append("site", site); + return form; +} + +describe("POST /api/delivery-notes/upload", () => { + beforeEach(() => { + createMock.mockReset(); + getFullListMock.mockReset(); + getOneMock.mockReset(); + // Default: dedup misses, site lookup returns project_code. + getFullListMock.mockResolvedValue([]); + getOneMock.mockResolvedValue({ project_code: "P-001" }); + }); + + it("returns 400 when the body is not multipart form data", async () => { + const res = await POST( + new Request("http://x/api/delivery-notes/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nope: true }), + }), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_form_data" }); + expect(createMock).not.toHaveBeenCalled(); + expect(getFullListMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when no file field is present", async () => { + const form = new FormData(); + form.append("site", "siteabc"); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "missing_file" }); + }); + + it("returns 400 for an empty file", async () => { + const form = new FormData(); + form.append("file", slip(0)); + form.append("site", "siteabc"); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "empty_file" }); + }); + + it("returns 400 when the file exceeds the size cap (20 MB)", async () => { + // Build a real oversized file so we don't rely on the request body + // actually being 21 MB on the wire — the size check happens before + // any hashing, and we don't want this test to allocate that much. + const big = slip(128); + Object.defineProperty(big, "size", { value: 21 * 1024 * 1024 }); + const form = makeValidForm(big); + const res = await POST({ + formData: async () => form, + } as unknown as Request); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("file_too_large"); + expect(body.max_bytes).toBe(20 * 1024 * 1024); + }); + + it("returns 400 for an unsupported MIME type", async () => { + const form = new FormData(); + form.append( + "file", + new File([new Uint8Array(16)], "dn.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }), + ); + form.append("site", "siteabc"); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("unsupported_type"); + expect(body.got).toContain("wordprocessingml"); + }); + + it("returns 400 when site is missing", async () => { + const form = new FormData(); + form.append("file", slip()); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "missing_site" }); + }); + + it("returns 400 when the site lookup fails", async () => { + getOneMock.mockRejectedValueOnce(new Error("not found")); + const res = await POST(makeRequest(makeValidForm())); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Site not found" }); + }); + + it("creates a DN + batch row, returns 201 with the new id", async () => { + createMock + .mockResolvedValueOnce({ + id: "dnnew1234567890", + created: "2026-09-07T10:00:00Z", + }) // delivery_notes.create + .mockResolvedValueOnce({ id: "batchabcdefghijkl" }); // batches.create + + const form = makeValidForm(); + form.append("delivery_note_number", "DN-5678"); + const res = await POST(makeRequest(form)); + + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ + id: "dnnew1234567890", + deliveryNoteNumber: "DN-5678", + siteId: "siteabc", + projectCode: "P-001", + processingStatus: "pending", + created: "2026-09-07T10:00:00Z", + }); + + expect(createMock).toHaveBeenCalledTimes(2); + + // First create: delivery_notes + const dnPayload = createMock.mock.calls[0][0]; + expect(dnPayload.delivery_note_number).toBe("DN-5678"); + expect(dnPayload.site).toBe("siteabc"); + expect(dnPayload.purchase_order).toBeNull(); + expect(dnPayload.processing_status).toBe("pending"); + expect(dnPayload.is_verified).toBe(false); + expect(dnPayload.received_by).toBe("web-upload"); + expect(dnPayload.slip_image).toBeInstanceOf(File); + + // Second create: data_import_batches with file_hash + const batchPayload = createMock.mock.calls[1][0]; + expect(batchPayload.batch_type).toBe("delivery_notes"); + expect(batchPayload.status).toBe("pending"); + expect(batchPayload.attempts).toBe(0); + expect(batchPayload.uploaded_by).toBe("web-upload"); + expect(batchPayload.processed_record_id).toBe("dnnew1234567890"); + expect(batchPayload.file_hash).toMatch(/^[a-f0-9]{64}$/); + }); + + it("computes the same hash for byte-identical files (so re-uploads dedup)", async () => { + createMock.mockResolvedValue({ id: "dnidxxxxxxxxxxxx" }); + const form1 = makeValidForm(slip(64)); + const form2 = makeValidForm(slip(64)); + + const r1 = await POST(makeRequest(form1)); + expect(r1.status).toBe(201); + const r2 = await POST(makeRequest(form2)); + // Second upload: dedup still misses (mocked getFullList returns []) + // so a second set of rows is created — but with the SAME file_hash. + expect(r2.status).toBe(201); + + const hash1 = createMock.mock.calls[1][0].file_hash; + const hash2 = createMock.mock.calls[3][0].file_hash; + expect(hash1).toBe(hash2); + }); + + it("returns 200 with duplicate=true when a recent processed batch has the same hash", async () => { + getFullListMock.mockResolvedValueOnce([ + { + id: "priorbatch1", + processed_record_id: "priordn1", + }, + ]); + const form = makeValidForm(slip()); + const res = await POST(makeRequest(form)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.duplicate).toBe(true); + expect(body.status).toBe("duplicate"); + expect(body.existing_batch_id).toBe("priorbatch1"); + expect(body.existing_dn_id).toBe("priordn1"); + expect(body.filename).toBe("dn-5678.jpg"); + // No new DN or batch row created on a dedup hit. + expect(createMock).not.toHaveBeenCalled(); + expect(getOneMock).not.toHaveBeenCalled(); + + // Verify the dedup query used the hash + status=processed + date cutoff. + const filter = getFullListMock.mock.calls[0][0].filter; + expect(filter).toContain('status = "processed"'); + expect(filter).toMatch(/file_hash = "[a-f0-9]{64}"/); + expect(filter).toContain("date_uploaded >="); + }); + + it("does not match a batch that is still pending (only 'processed' counts)", async () => { + // If only pending batches match, dedup misses and a normal create runs. + getFullListMock.mockResolvedValueOnce([]); // empty as if no processed match + createMock.mockResolvedValueOnce({ id: "dnnew1" }); + createMock.mockResolvedValueOnce({ id: "batch1" }); + + const res = await POST(makeRequest(makeValidForm())); + + expect(res.status).toBe(201); + const filter = getFullListMock.mock.calls[0][0].filter; + expect(filter).toContain('status = "processed"'); + }); + + it("uses the 30-day cutoff in the dedup query", async () => { + // Don't actually return a match — we only care about the filter shape. + getFullListMock.mockResolvedValueOnce([]); + createMock.mockResolvedValueOnce({ id: "dnx" }); + createMock.mockResolvedValueOnce({ id: "batchx" }); + + await POST(makeRequest(makeValidForm())); + + const filter = getFullListMock.mock.calls[0][0].filter; + const cutoffMatch = filter.match(/date_uploaded >= "([^"]+)"/); + expect(cutoffMatch).not.toBeNull(); + const cutoff = new Date(cutoffMatch![1]).getTime(); + const expected = Date.now() - DEDUP_WINDOW_DAYS * 86400_000; + expect(Math.abs(cutoff - expected)).toBeLessThan(5_000); + // Sanity-check we're not accidentally using daysAgoIso(0). + expect(daysAgoIso(0)).not.toBe(cutoffMatch![1]); + }); + + it("falls through to create when the dedup query throws", async () => { + getFullListMock.mockRejectedValueOnce(new Error("pb dedup down")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + createMock.mockResolvedValueOnce({ id: "dnz" }); + createMock.mockResolvedValueOnce({ id: "batchz" }); + + const res = await POST(makeRequest(makeValidForm())); + + expect(res.status).toBe(201); + expect(createMock).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("still creates a DN when the batch-tracking row fails (OCR can still pick it up)", async () => { + getOneMock.mockResolvedValue({ project_code: "P-001" }); + createMock.mockResolvedValueOnce({ id: "dnonlyone1" }); // DN succeeds + createMock.mockRejectedValueOnce(new Error("batches broken")); // batches fails + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const res = await POST(makeRequest(makeValidForm())); + + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ id: "dnonlyone1" }); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("returns 500 when the DN row cannot be created", async () => { + createMock.mockRejectedValueOnce(new Error("pb down")); + const res = await POST(makeRequest(makeValidForm())); + expect(res.status).toBe(500); + const body = await res.json(); + // The route surfaces the underlying PB error so the operator can see + // why the create failed; we just assert it's the 500 payload. + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/api/delivery-notes/upload/route.ts b/src/app/api/delivery-notes/upload/route.ts index 8245d3a..af416a0 100644 --- a/src/app/api/delivery-notes/upload/route.ts +++ b/src/app/api/delivery-notes/upload/route.ts @@ -1,6 +1,23 @@ import { NextResponse } from "next/server"; +import { createHash } from "node:crypto"; import { pbAdmin } from "@/lib/pb"; +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; // 20 MB + +// Dedup window for byte-identical re-uploads. Matches the PO upload +// route's window. Anything older than this is ignored — the OCR +// pipeline's `delivery_note` relation is the source of truth for those. +const DEDUP_WINDOW_DAYS = 30; + /** * POST /api/delivery-notes/upload * @@ -19,22 +36,110 @@ import { pbAdmin } from "@/lib/pb"; * - processing_status = "pending" so the n8n OCR pipeline picks it up * - purchase_order = null (filled in by OCR webhook on match) * - date_received = upload time + * + * Hash dedup: before creating rows, we SHA-256 the bytes and look for a + * recent (last 30 days) `data_import_batches` row with the same hash that + * already reached `status=processed`. If we find one, we short-circuit + * with `{ duplicate: true, existing_batch_id, existing_dn_id, ... }` — + * no new DN, no new batch row, no wasted OCR tokens. Mirrors the + * behaviour of /api/purchase-orders/upload so the operator gets the + * same experience on both upload paths. */ export async function POST(request: Request) { - const form = await request.formData(); - const file = form.get("file"); - const siteId = (form.get("site") as string | null)?.trim(); - const deliveryNoteNumber = (form.get("delivery_note_number") as string | null)?.trim(); + const start = Date.now(); - if (!(file instanceof File)) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); + 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_MIMES.includes(file.type as (typeof ALLOWED_MIMES)[number])) { + return NextResponse.json( + { + error: "unsupported_type", + got: file.type, + allowed: [...ALLOWED_MIMES], + }, + { status: 400 }, + ); + } + + const siteId = (form.get("site") as string | null)?.trim(); + const deliveryNoteNumber = ( + form.get("delivery_note_number") as string | null + )?.trim(); + if (!siteId) { - return NextResponse.json({ error: "Missing site" }, { status: 400 }); + return NextResponse.json({ error: "missing_site" }, { status: 400 }); + } + + // Hash the file bytes. Cheap enough to do before the PB round-trip + // (20 MB worst case, but in practice slips are a few hundred KB). + let file_hash: string; + try { + const bytes = Buffer.from(await file.arrayBuffer()); + file_hash = createHash("sha256").update(bytes).digest("hex"); + } catch (err) { + console.error("[dn-upload] hash_failed", { err: String(err) }); + return NextResponse.json({ error: "hash_failed" }, { status: 500 }); } const pb = await pbAdmin(); + // Hash dedup: look for a recent processed batch with the same hash. + // Uses the idx_batches_file_hash index on (file_hash, status). + let existing: Array<{ id: string; processed_record_id?: string }> = []; + try { + const cutoff = new Date( + Date.now() - DEDUP_WINDOW_DAYS * 86400_000, + ).toISOString(); + existing = await pb.collection("data_import_batches").getFullList({ + filter: `file_hash = "${file_hash}" && status = "processed" && date_uploaded >= "${cutoff}"`, + sort: "-date_uploaded", + fields: "id,processed_record_id", + }); + } catch (err) { + // Dedup query failing is non-fatal — log and proceed to create the + // DN. Worst case is we miss the dedup and the operator will spot + // it manually on the awaiting-PO assignment inbox. + console.warn("[dn-upload] dedup_query_failed", { err: String(err) }); + } + + if (existing.length > 0) { + const prior = existing[0]; + console.log("[dn-upload] duplicate_by_hash", { + file_hash, + existing_batch_id: prior.id, + existing_dn_id: prior.processed_record_id ?? null, + duration_ms: Date.now() - start, + }); + return NextResponse.json( + { + duplicate: true, + status: "duplicate", + existing_batch_id: prior.id, + existing_dn_id: prior.processed_record_id ?? null, + filename: file.name, + }, + { status: 200 }, + ); + } + // Resolve site → project_code for the DN's project_code field (used by // older reports that filter by text rather than the relation). let resolvedProject = ""; @@ -44,10 +149,7 @@ export async function POST(request: Request) { }); resolvedProject = site.project_code ?? ""; } catch { - return NextResponse.json( - { error: "Site not found" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Site not found" }, { status: 400 }); } try { @@ -68,7 +170,8 @@ export async function POST(request: Request) { // the dashboard's "imports" view) can track this upload through // pending → processed/failed/dead. The row is linked back to the // delivery_note via `processed_record_id` so the OCR result route - // can find it without a relation field on delivery_notes. + // can find it without a relation field on delivery_notes. We also + // stamp `file_hash` so a future re-upload can dedup against it. try { await pb.collection("data_import_batches").create({ batch_type: "delivery_notes", @@ -78,6 +181,7 @@ export async function POST(request: Request) { date_uploaded: new Date().toISOString(), uploaded_by: "web-upload", original_file: file, + file_hash, processed_record_id: dn.id, }); } catch (batchErr) { @@ -91,14 +195,17 @@ export async function POST(request: Request) { ); } - return NextResponse.json({ - id: dn.id, - deliveryNoteNumber: deliveryNoteNumber || "", - siteId, - projectCode: resolvedProject, - processingStatus: "pending", - created: dn.created, - }); + return NextResponse.json( + { + id: dn.id, + deliveryNoteNumber: deliveryNoteNumber || "", + siteId, + projectCode: resolvedProject, + processingStatus: "pending", + created: dn.created, + }, + { status: 201 }, + ); } catch (err) { return NextResponse.json( { diff --git a/src/components/upload-delivery-note-button.tsx b/src/components/upload-delivery-note-button.tsx index 7182aa1..f140b75 100644 --- a/src/components/upload-delivery-note-button.tsx +++ b/src/components/upload-delivery-note-button.tsx @@ -1,7 +1,15 @@ "use client"; import * as React from "react"; -import { FileSpreadsheet, Loader2, Upload, X } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + Copy, + FileSpreadsheet, + Loader2, + Upload, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -21,7 +29,7 @@ import { import { useFileDropZone } from "@/lib/use-file-drop-zone"; import { cn } from "@/lib/utils"; -type Phase = "idle" | "uploading" | "done" | "error"; +type Phase = "idle" | "uploading" | "done" | "duplicate" | "error"; interface UploadResult { id: string; @@ -32,6 +40,19 @@ interface UploadResult { created: string; } +/** + * Returned by /api/delivery-notes/upload when SHA-256 dedup found a + * matching file within the configured window. We don't create a new + * DN — the operator should switch to the existing one. Shape mirrors + * the PO upload `duplicate` payload, just with `existing_dn_id` + * instead of `existing_po_id`. + */ +interface DuplicateInfo { + existingDnId: string | null; + existingBatchId: string | null; + filename: string; +} + export interface SiteOption { id: string; name: string; @@ -57,6 +78,7 @@ export function UploadDeliveryNoteButton({ const [file, setFile] = React.useState(null); const [phase, setPhase] = React.useState("idle"); const [result, setResult] = React.useState(null); + const [duplicate, setDuplicate] = React.useState(null); const [errorMsg, setErrorMsg] = React.useState(null); const inputRef = React.useRef(null); @@ -66,6 +88,7 @@ export function UploadDeliveryNoteButton({ setFile(null); setPhase("idle"); setResult(null); + setDuplicate(null); setErrorMsg(null); if (inputRef.current) inputRef.current.value = ""; }, []); @@ -79,6 +102,7 @@ export function UploadDeliveryNoteButton({ if (!file || !siteId) return; setPhase("uploading"); setErrorMsg(null); + setDuplicate(null); try { const form = new FormData(); form.append("file", file); @@ -89,6 +113,25 @@ export function UploadDeliveryNoteButton({ body: form, }); const data = await res.json(); + // 200 + duplicate:true = server caught a byte-identical recent + // upload. No new DN was created — route the operator to the + // existing one instead. + if (res.status === 200 && data && data.duplicate === true) { + setDuplicate({ + existingDnId: + typeof data.existing_dn_id === "string" + ? data.existing_dn_id + : null, + existingBatchId: + typeof data.existing_batch_id === "string" + ? data.existing_batch_id + : null, + filename: + typeof data.filename === "string" ? data.filename : file.name, + }); + setPhase("duplicate"); + return; + } if (!res.ok) { setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed"); setPhase("error"); @@ -154,6 +197,8 @@ export function UploadDeliveryNoteButton({ {phase === "done" && result ? ( + ) : phase === "duplicate" && duplicate ? ( + ) : (
@@ -333,3 +378,69 @@ function DonePanel({
); } + +/** + * Shown when the upload route returns 200 + `duplicate: true`. The + * server hashed the bytes and matched a recent processed batch — we did + * NOT create a new DN, so the operator's options are: (a) leave and + * look up the existing row themselves, (b) re-upload anyway by first + * editing the existing DN (we don't expose that flow here on purpose), + * or (c) open the existing one via the purchasing page. + */ +function DuplicatePanel({ + duplicate, + onClose, +}: { + duplicate: DuplicateInfo; + onClose: () => void; +}) { + const { existingDnId, existingBatchId, filename } = duplicate; + return ( +
+
+

+ + This slip has already been uploaded +

+

+ The file {filename}{" "} + matched a delivery note that's already in the system. No new + record was created, and no OCR was spent on this attempt — we + stopped at the hash check to avoid a duplicate. +

+ {existingDnId && ( +

+ Existing DN: + + {existingDnId} + +

+ )} + {existingBatchId && ( +

+ Batch row: + {existingBatchId} +

+ )} +
+ +

+ If the original upload had OCR problems (no PO auto-matched, + missing DN number, etc.), open the row in the awaiting-PO + inbox to fix it rather than re-uploading. +

+
+
+ +
+ ); +} diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index 1f0c4ed..f19c9c7 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -144,6 +144,15 @@ export interface DataImportBatchRecord extends RecordModel { next_retry_at?: string; processed_at?: string; processed_record_id?: string; + /** + * Lowercase hex SHA-256 of the uploaded file's bytes. Populated by the + * upload routes so byte-identical re-uploads can dedup within the + * configured window (currently 30 days). Indexed as + * `idx_batches_file_hash` on the `data_import_batches` collection for + * the dedup query — see `src/app/api/purchase-orders/upload/route.ts` + * and `src/app/api/delivery-notes/upload/route.ts`. + */ + file_hash?: string; } export interface PurchaseOrderRecord extends RecordModel {