MEGAINFRA-WEBAPP/docs/superpowers/specs/2026-08-19-purchase-order-upload-design.md
DEls 8f37f3467f Spec: clarify PO upload dialog does not auto-close
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 15:03:48 +02:00

13 KiB
Raw Blame History

Purchase Order Upload + n8n Ingestion — Design

Date: 2026-08-19 Status: Approved (pending implementation) Owner: DELs

Goal

Let users upload Purchase Order documents (PDF or scanned image) from the /dashboard/purchasing page, have an n8n workflow extract the PO data, and land a verified-ready record in the existing purchase_orders collection — without giving n8n PocketBase admin credentials and without blocking the user on OCR.

Non-goals (v1)

  • Email-forwarded PO ingestion (same /ingest endpoint will be reused later).
  • Bulk multi-file upload in one dialog.
  • Re-running OCR on an existing PO.
  • Editing PO fields post-extraction via a form (the existing VerifyButton flow is enough for v1).
  • Realtime UI updates — force-dynamic page re-fetch is sufficient.

Architecture

Browser → Next.js API → n8n → Next.js callback → PocketBase

All PocketBase writes stay in pbAdmin() (matches existing pattern in src/app/api/delivery-notes/upload/route.ts and src/app/api/timesheets/import/route.ts). n8n authenticates to the callback via a shared secret header — it never holds PocketBase credentials.

┌──────────┐  multipart   ┌──────────────────┐  forward   ┌────────────┐
│ Browser  │  ──────────► │ Next.js          │  ────────► │ n8n        │
│ (Upload  │              │ /api/purchase-   │   file     │ webhook    │
│  Button) │ ◄──────────  │  orders/upload   │ ◄────────  │ (OCR +     │
│          │  202 proc.  │                  │  202 ack   │  parse)    │
└──────────┘              └──────────────────┘            └─────┬──────┘
                                                                │
                                                                │ callback
                                                                ▼
                                                       ┌──────────────────┐
                                                       │ /api/purchase-   │
                                                       │  orders/ingest   │
                                                       │ (verify secret)  │
                                                       └───────┬──────────┘
                                                               │ pbAdmin()
                                                               ▼
                                                       ┌────────────────┐
                                                       │ PocketBase     │
                                                       │ purchase_orders│
                                                       │ + items        │
                                                       └────────────────┘

Step-by-step

  1. User clicks "Upload Purchase Order" in the Purchasing topbar.
  2. Browser POSTs the file (PDF/JPG/PNG/WEBP, ≤20 MB) to /api/purchase-orders/upload as multipart/form-data with fields file, filename, contentType, size.
  3. Next.js /api/purchase-orders/upload:
    • Validates file type + size (early-return 400 if bad).
    • Forwards the file via fetch to ${N8N_PO_INGEST_URL} (server-only env var).
    • Returns 202 { status: "processing", filename } immediately — does not wait for OCR.
  4. n8n receives the file via webhook, runs OCR (Tesseract for images, pdf-parse or equivalent for PDFs), parses PO fields + line items, then POSTs the structured payload to /api/purchase-orders/ingest with header x-ingest-secret: ${N8N_INGEST_SECRET}.
  5. Next.js /api/purchase-orders/ingest:
    • Verifies the secret header.
    • Validates payload with the shared schema validator.
    • Looks up site by project_code (exact → starts-with → no-match).
    • Creates the purchase_orders record (with file + denormalized fields).
    • Creates each purchase_order_items row in a sequential for loop inside the route handler (PocketBase SDK has no true batch insert — each create() is a separate HTTP request, but they all share the same pbAdmin() auth and run within the single ingest request).
    • Returns 200 { ok: true, id, po_number, warnings? } to n8n.
  6. Dashboard is force-dynamic — next page load shows the new PO.

Data model

PocketBase collections

purchase_orders — see src/lib/pb-types.ts:76-90 and scripts/pb-bootstrap-schema.py:104-125. No schema changes needed.

purchase_order_items — see src/lib/pb-types.ts:92-106.

Payload contract (n8n → /ingest)

export type PurchaseOrderIngestPayload = {
  po_number: string;             // required, unique-indexed
  project_code: string;          // required (used to match site)
  supplier: string;              // required
  required_by_date?: string;     // ISO date "YYYY-MM-DD"
  ordered_by?: string;
  items: PurchaseOrderItemPayload[];
  file: {
    filename: string;
    contentType: "application/pdf" | "image/jpeg" | "image/png" | "image/webp";
    base64: string;              // no data: prefix
  };
  raw_ocr: Record<string, unknown>;
};

export type PurchaseOrderItemPayload = {
  material_code?: string;
  cost_code?: string;
  description: string;
  qty: number;
  unit?: string;
  rate: number;
  disc_percent?: number;
  vat_percent?: number;
};

Validation rules (in src/lib/purchase-order-schema.ts)

  • po_number, project_code, supplier — non-empty strings.
  • items — non-empty array; each item has description, qty > 0, rate >= 0.
  • file.base64 — valid base64, decoded size ≤ 20 MB.
  • file.contentType — must be in the allowlist.
  • On failure: route returns 400 { error, details }.

Site matching

In /api/purchase-orders/ingest (implemented as a small helper in src/lib/purchase-order-schema.ts):

  1. Exact match — query pbAdmin().collection("sites").getFullList({ filter: \project_code = "${project_code}"` })`.
  2. Starts-with fallback — if no exact match, query with filter: \project_code ~ "${project_code}"`(PocketBase's~` operator is case-insensitive contains) and prefer the shortest result.
  3. No match — leave site empty, set raw_ocr_data.unmatched_project_code = true. PO is still created (user can link manually later).
  4. Multiple matches at any stage → use the first. Acceptable for v1; flagged as known limitation. A warning is added to the response so n8n logs it.
  5. The denormalized project_code field on the PO record is always populated from the payload (list view works even before site is matched).

Derived fields

Written by the ingest route, not trusted from n8n:

  • items_count = items.length.
  • total_value = sum of qty * rate * (1 - disc_percent/100) * (1 + vat_percent/100) per item, rounded to 2 decimals.
  • purchase_order_file = reconstructed from file.base64 → Buffer → Blob → passed to PocketBase as File.
  • raw_ocr_data = { ...raw_ocr, ingested_at: ISO timestamp, matched_site_id: string | null }.
  • is_verified = false (manager verifies via existing VerifyButton).

Response back to n8n

Success:

{ "ok": true, "id": "<pocketbase_record_id>", "po_number": "PO-1234", "warnings": ["site_unmatched"] }

Errors:

  • 400 { error, details } — malformed payload.
  • 401 { error: "unauthorized" } — missing or wrong secret.
  • 409 { error: "duplicate_po_number", existing_id } — collision.
  • 500 { error, step } — step ∈ "decode_file" | "lookup_site" | "create_po" | "create_items".

Files

New

File Purpose
src/components/upload-purchase-order-button.tsx Upload button + dialog. Mirrors upload-delivery-note-button.tsx.
src/app/api/purchase-orders/upload/route.ts Forwards file to n8n, returns 202.
src/app/api/purchase-orders/ingest/route.ts Webhook ingress; verifies secret; writes to PocketBase.
src/lib/purchase-order-schema.ts Shared payload types + runtime validator + site-matching helper.
docs/n8n/purchase-order-ingest.md Handoff doc for building the n8n workflow.

Modified

File Change
src/app/dashboard/purchasing/page.tsx Add <UploadPurchaseOrderButton /> to Topbar's right slot, next to the existing <UploadDeliveryNoteButton />.
.env.local.example Add N8N_PO_INGEST_URL= and N8N_INGEST_SECRET= with inline comments.

Unchanged

  • src/lib/pb.ts, src/lib/pb-types.ts, scripts/pb-bootstrap-schema.py — schema already supports everything.
  • src/components/upload-delivery-note-button.tsx — left untouched.
  • src/components/layout/sidebar.tsx — Topbar already supports the right slot.
  • src/app/dashboard/purchasing/purchasing-view.tsx — empty-state copy at line 266 still accurate.

UX

Upload button (UploadPurchaseOrderButton)

Mirrors UploadDeliveryNoteButton:

  • "use client" directive.
  • Phase state machine: idle | uploading | done | error.
  • Radix Dialog (max-w-xl), drop zone, submit, success panel.
  • accept="application/pdf,image/jpeg,image/png,image/webp".
  • Client-side size check (file.size <= 20_000_000).
  • Submits FormData to /api/purchase-orders/upload.
  • Success: "Uploaded — being processed by n8n. The PO will appear here once extraction finishes." Stays open until the user clicks "Done" (no auto-close). No refresh button — force-dynamic re-fetches on next nav.
  • Error: red panel with server's error message.

Async UX rationale

OCR can take 30+ seconds. Blocking the user is bad. They get instant "processing" feedback and can navigate. The new PO appears on their next visit.

Error handling

User-facing

Scenario Behaviour
File too big or wrong type Inline error before submit.
n8n webhook unreachable Red error panel: "Upload failed — n8n ingestion service unreachable."
202 success Success panel (see above).
Non-202 Red error panel with server message.

n8n callback

Scenario Route behaviour
Bad secret header 401. Log warning. No PB writes.
Malformed payload 400. n8n logs + retries per its policy.
Duplicate po_number 409. n8n logs; no overwrite. Route is idempotent.
Site not matched 200 with warnings. PO still created.
PB write fails mid-way 500 { error, step }. n8n retries.

n8n retry policy (documented in handoff)

  • Retry 500 and 408 up to 3× with exponential backoff (10s, 30s, 90s).
  • Do not retry 400 or 401 — configuration errors.
  • On 409, log + dead-letter (n8n "Error" branch).

Observability

Both routes log with [po-upload] / [po-ingest] prefixes:

  • [po-upload] success: { filename, size, contentType, n8n_status, duration_ms }.
  • [po-ingest] success: { po_number, project_code, matched_site_id, items_count, total_value, duration_ms, warnings }.
  • [po-ingest] failure: full error + step.

Environment variables

Add to .env.local (and document in .env.local.example):

# n8n webhook URL for the PO ingestion trigger.
# Server-only (no NEXT_PUBLIC_ prefix) — never exposed to the browser.
N8N_PO_INGEST_URL=https://n8n.your-host.example/webhook/purchase-order-upload

# Shared secret n8n must send in the x-ingest-secret header when calling
# /api/purchase-orders/ingest. Generate with: openssl rand -hex 32
N8N_INGEST_SECRET=<32-byte-hex-string>

Testing

Unit / route tests (Vitest)

  • purchase-order-schema.ts validator:
    • Valid payload → no errors.
    • Missing required field → specific error.
    • Oversized file (decoded >20 MB) → specific error.
    • Bad base64 → specific error.
    • Empty items array → specific error.
  • Site-matching helper:
    • Exact match → returns id.
    • Starts-with match → returns id.
    • No match → returns null + sets warning.

Integration / smoke tests (manual, post-deploy)

  • Upload a real PO PDF via the button → confirm n8n receives it (check n8n execution history).
  • With n8n mocked via a test webhook returning a canned payload, POST a valid payload to /api/purchase-orders/ingest with the correct secret → confirm purchase_orders + purchase_order_items rows appear in PocketBase.
  • POST with wrong secret → expect 401.
  • POST with duplicate po_number → expect 409.
  • POST with unmatched project_code → expect 200 with warnings: ["site_unmatched"]; verify PO created with site empty and raw_ocr_data.unmatched_project_code = true.
  • After ingest, reload /dashboard/purchasing → new PO appears with correct total_value and items_count.

Out of test scope (v1)

  • OCR accuracy (n8n's territory).
  • PocketBase schema changes (none being made).
  • Realtime UX (not being built).