MEGAINFRA-WEBAPP/docs/superpowers/plans/2026-08-19-purchase-order-upload.md
DEls 24fc85d911 Plan: Purchase Order upload + n8n ingestion implementation
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 02:16:22 +02:00

40 KiB
Raw Blame History

Purchase Order Upload + n8n Ingestion — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a Purchase Order upload button on /dashboard/purchasing that forwards PO files (PDF or scanned image) to an n8n workflow for OCR/extraction; the n8n workflow then calls back to a Next.js webhook that writes the parsed PO + line items to PocketBase.

Architecture: Browser → Next.js /api/purchase-orders/upload (forwards file) → n8n (OCR + parse) → Next.js /api/purchase-orders/ingest (shared-secret verified, writes via pbAdmin()) → PocketBase purchase_orders + purchase_order_items. All PocketBase writes stay in pbAdmin(); n8n never holds PB credentials.

Tech Stack: Next.js 16 (App Router, async params), React 19, TypeScript, PocketBase SDK 0.27, Vitest (newly added), Radix Dialog/Button/Input, Tailwind, native fetch + FormData.

Spec: docs/superpowers/specs/2026-08-19-purchase-order-upload-design.md n8n handoff: docs/n8n/purchase-order-ingest.md


File Structure

New files (5):

  • src/lib/purchase-order-schema.ts — payload types, runtime validator, site-matching helper, total-value calculator (pure functions; unit-tested).
  • src/lib/purchase-order-schema.test.ts — Vitest suite for the above.
  • src/app/api/purchase-orders/upload/route.ts — receives multipart file, forwards to n8n webhook, returns 202.
  • src/app/api/purchase-orders/ingest/route.ts — n8n callback: verifies secret, validates payload, matches site, writes PO + line items.
  • src/components/upload-purchase-order-button.tsx — client button + dialog mirroring upload-delivery-note-button.tsx.

Modified files (3):

  • src/app/dashboard/purchasing/page.tsx — mount <UploadPurchaseOrderButton /> next to <UploadDeliveryNoteButton /> in the Topbar's right slot.
  • .env.local.example — add N8N_PO_INGEST_URL and N8N_INGEST_SECRET.
  • package.json — add Vitest dev-dep + test script.

Modified files (1, infrastructure):

  • vitest.config.ts — newly created; minimal config for the Node environment.

Unchanged: schema (scripts/pb-bootstrap-schema.py), PB types (src/lib/pb-types.ts), PB client (src/lib/pb.ts), existing upload components, sidebar.


Task 1: Add Vitest and document env vars

Files:

  • Modify: package.json

  • Create: vitest.config.ts

  • Modify: .env.local.example

  • Step 1: Install Vitest as a dev dependency

Run from repo root:

npm install --save-dev vitest@^2.1.0

Expected: package.json devDependencies gains "vitest": "^2.1.0", node_modules/vitest exists.

  • Step 2: Add the test script and test:run script to package.json

Open package.json. Replace the "scripts" block (keep all existing entries) so it reads:

"scripts": {
  "dev": "next dev -H 0.0.0.0 -p 6004 --webpack",
  "build": "npm run og-image && next build --webpack",
  "og-image": "node scripts/build-og-image.mjs",
  "start": "next start -H 0.0.0.0 -p 3000",
  "lint": "next lint",
  "typecheck": "tsc --noEmit",
  "test": "vitest",
  "test:run": "vitest run",
  "postinstall": "node scripts/patch-next-dev-blocker.cjs"
}
  • Step 3: Create vitest.config.ts at repo root
import { defineConfig } from "vitest/config";
import path from "node:path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
  test: {
    environment: "node",
    include: ["src/**/*.test.ts"],
  },
});
  • Step 4: Add n8n env vars to .env.local.example

Open .env.local.example. Append at the end of the file:


# ── n8n (Purchase Order ingestion) ──────────────────────────────────────
# Webhook URL the Next.js server forwards PO uploads to.
# 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 sends in the x-ingest-secret header when calling
# /api/purchase-orders/ingest. Generate with: openssl rand -hex 32
N8N_INGEST_SECRET=replace-with-output-of-openssl-rand-hex-32
  • Step 5: Verify Vitest runs

Run: npm run test:run Expected: "No test files found" exit 0 (no tests yet). Confirms the runner is wired.

  • Step 6: Commit
git add package.json package-lock.json vitest.config.ts .env.local.example
git commit -m "Add Vitest + n8n env var documentation"

Task 2: Schema types + validator + site-matching helper (TDD)

Files:

  • Create: src/lib/purchase-order-schema.ts

  • Create: src/lib/purchase-order-schema.test.ts

  • Step 1: Write the failing test file

Create src/lib/purchase-order-schema.test.ts:

import { describe, it, expect } from "vitest";
import {
  validatePurchaseOrderPayload,
  matchSite,
  computeTotalValue,
  type PurchaseOrderIngestPayload,
} from "./purchase-order-schema";

const VALID_PAYLOAD: PurchaseOrderIngestPayload = {
  po_number: "PO-1234",
  project_code: "C26/09",
  supplier: "Acme",
  required_by_date: "2026-09-01",
  ordered_by: "Jane",
  items: [{ description: "Cement", qty: 10, rate: 50 }],
  file: {
    filename: "po.pdf",
    contentType: "application/pdf",
    base64: Buffer.from("hello").toString("base64"),
  },
  raw_ocr: { source: "tesseract" },
};

describe("validatePurchaseOrderPayload", () => {
  it("accepts a valid payload", () => {
    const result = validatePurchaseOrderPayload(VALID_PAYLOAD);
    expect(result.ok).toBe(true);
  });

  it("rejects missing po_number", () => {
    const { po_number: _omit, ...rest } = VALID_PAYLOAD;
    const result = validatePurchaseOrderPayload(rest);
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.details?.po_number).toBeDefined();
  });

  it("rejects empty items array", () => {
    const result = validatePurchaseOrderPayload({ ...VALID_PAYLOAD, items: [] });
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.details?.items).toBeDefined();
  });

  it("rejects item with qty <= 0", () => {
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      items: [{ description: "x", qty: 0, rate: 1 }],
    });
    expect(result.ok).toBe(false);
  });

  it("rejects item with negative rate", () => {
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      items: [{ description: "x", qty: 1, rate: -5 }],
    });
    expect(result.ok).toBe(false);
  });

  it("rejects bad date format", () => {
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      required_by_date: "01-09-2026",
    });
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.details?.required_by_date).toBeDefined();
  });

  it("accepts payload with optional fields missing", () => {
    const { required_by_date: _d, ordered_by: _o, ...rest } = VALID_PAYLOAD;
    const result = validatePurchaseOrderPayload(rest);
    expect(result.ok).toBe(true);
  });

  it("rejects oversized file (>20MB decoded)", () => {
    const big = Buffer.alloc(21 * 1024 * 1024).toString("base64");
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      file: { ...VALID_PAYLOAD.file, base64: big },
    });
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.details?.["file.base64"]).toMatch(/exceeds/);
  });

  it("rejects unsupported MIME", () => {
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      file: { ...VALID_PAYLOAD.file, contentType: "application/zip" },
    });
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.details?.["file.contentType"]).toBeDefined();
  });

  it("rejects empty base64", () => {
    const result = validatePurchaseOrderPayload({
      ...VALID_PAYLOAD,
      file: { ...VALID_PAYLOAD.file, base64: "" },
    });
    expect(result.ok).toBe(false);
  });

  it("rejects non-object payload", () => {
    const result = validatePurchaseOrderPayload("not an object");
    expect(result.ok).toBe(false);
  });
});

describe("computeTotalValue", () => {
  it("sums qty * rate without discount/vat", () => {
    expect(
      computeTotalValue([
        { description: "a", qty: 2, rate: 10 },
        { description: "b", qty: 3, rate: 5 },
      ]),
    ).toBe(35);
  });

  it("applies discount and vat per item", () => {
    expect(
      computeTotalValue([
        { description: "a", qty: 1, rate: 100, disc_percent: 10, vat_percent: 15 },
      ]),
    ).toBe(103.5);
  });

  it("returns 0 for empty array", () => {
    expect(computeTotalValue([])).toBe(0);
  });

  it("rounds to 2 decimals", () => {
    expect(
      computeTotalValue([
        { description: "a", qty: 3, rate: 0.1 + 0.2, disc_percent: 0, vat_percent: 0 },
      ]),
    ).toBe(0.9);
  });
});

describe("matchSite", () => {
  function fakePb(sites: Array<{ id: string; project_code: string }>) {
    return {
      collection: (_name: string) => ({
        getFullList: async (opts: { filter?: string }) => {
          const filter = opts.filter ?? "";
          const eqMatch = filter.match(/project_code = "([^"]+)"/);
          const fuzzyMatch = filter.match(/project_code ~ "([^"]+)"/);
          const target = eqMatch?.[1] ?? fuzzyMatch?.[1];
          if (!target) return [];
          if (eqMatch) {
            return sites.filter((s) => s.project_code === target);
          }
          return sites.filter((s) =>
            s.project_code.toLowerCase().includes(target.toLowerCase()),
          );
        },
      }),
    };
  }

  it("returns first exact match", async () => {
    const pb = fakePb([
      { id: "a", project_code: "C26/09" },
      { id: "b", project_code: "C26/10" },
    ]);
    const r = await matchSite(pb as never, "C26/09");
    expect(r.siteId).toBe("a");
    expect(r.matched).toBe(true);
    expect(r.warning).toBeUndefined();
  });

  it("falls back to fuzzy (contains) match when no exact", async () => {
    const pb = fakePb([{ id: "x", project_code: "C26/09-EXT" }]);
    const r = await matchSite(pb as never, "C26/09");
    expect(r.siteId).toBe("x");
    expect(r.matched).toBe(true);
  });

  it("returns unmatched + warning when nothing found", async () => {
    const pb = fakePb([{ id: "y", project_code: "DIFFERENT" }]);
    const r = await matchSite(pb as never, "C26/09");
    expect(r.siteId).toBeNull();
    expect(r.matched).toBe(false);
    expect(r.warning).toBe("site_unmatched");
  });

  it("picks shortest fuzzy match when multiple candidates", async () => {
    const pb = fakePb([
      { id: "long", project_code: "C26/09-EXTENSION-LONG" },
      { id: "short", project_code: "C26/09" },
    ]);
    const r = await matchSite(pb as never, "C26/09");
    expect(r.siteId).toBe("short");
  });
});
  • Step 2: Run tests to verify they fail

Run: npm run test:run Expected: All tests FAIL with "Cannot find module './purchase-order-schema'" or similar.

  • Step 3: Implement the schema module

Create src/lib/purchase-order-schema.ts:

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

export type PurchaseOrderIngestPayload = {
  po_number: string;
  project_code: string;
  supplier: string;
  required_by_date?: string;
  ordered_by?: string;
  items: PurchaseOrderItemPayload[];
  file: {
    filename: string;
    contentType: "application/pdf" | "image/jpeg" | "image/png" | "image/webp";
    base64: string;
  };
  raw_ocr: Record<string, unknown>;
};

const ALLOWED_MIME = new Set([
  "application/pdf",
  "image/jpeg",
  "image/png",
  "image/webp",
]);
const MAX_FILE_BYTES = 20 * 1024 * 1024;

export type ValidationResult =
  | { ok: true; payload: PurchaseOrderIngestPayload }
  | { ok: false; error: string; details?: Record<string, string> };

export function validatePurchaseOrderPayload(input: unknown): ValidationResult {
  if (!input || typeof input !== "object") {
    return { ok: false, error: "payload_not_object" };
  }
  const p = input as Record<string, unknown>;
  const details: Record<string, string> = {};

  if (typeof p.po_number !== "string" || p.po_number.trim() === "") {
    details.po_number = "must be a non-empty string";
  }
  if (typeof p.project_code !== "string" || p.project_code.trim() === "") {
    details.project_code = "must be a non-empty string";
  }
  if (typeof p.supplier !== "string" || p.supplier.trim() === "") {
    details.supplier = "must be a non-empty string";
  }
  if (
    p.required_by_date !== undefined &&
    (typeof p.required_by_date !== "string" ||
      !/^\d{4}-\d{2}-\d{2}$/.test(p.required_by_date))
  ) {
    details.required_by_date = "must be ISO date YYYY-MM-DD";
  }
  if (p.ordered_by !== undefined && typeof p.ordered_by !== "string") {
    details.ordered_by = "must be a string";
  }

  if (!Array.isArray(p.items) || p.items.length === 0) {
    details.items = "must be a non-empty array";
  } else {
    const itemErrors: string[] = [];
    p.items.forEach((item, i) => {
      if (!item || typeof item !== "object") {
        itemErrors.push(`items[${i}]: not an object`);
        return;
      }
      const it = item as Record<string, unknown>;
      if (typeof it.description !== "string" || it.description.trim() === "") {
        itemErrors.push(`items[${i}].description: required`);
      }
      if (typeof it.qty !== "number" || !(it.qty > 0)) {
        itemErrors.push(`items[${i}].qty: must be a number > 0`);
      }
      if (typeof it.rate !== "number" || it.rate < 0) {
        itemErrors.push(`items[${i}].rate: must be a number >= 0`);
      }
    });
    if (itemErrors.length > 0) {
      details.items = itemErrors.join("; ");
    }
  }

  if (!p.file || typeof p.file !== "object") {
    details.file = "required";
  } else {
    const f = p.file as Record<string, unknown>;
    if (typeof f.filename !== "string" || f.filename.trim() === "") {
      details["file.filename"] = "must be a non-empty string";
    }
    if (
      typeof f.contentType !== "string" ||
      !ALLOWED_MIME.has(f.contentType)
    ) {
      details["file.contentType"] = `must be one of ${[...ALLOWED_MIME].join(", ")}`;
    }
    if (typeof f.base64 !== "string") {
      details["file.base64"] = "must be a string";
    } else {
      const buf = Buffer.from(f.base64, "base64");
      if (buf.length === 0) {
        details["file.base64"] = "decoded to 0 bytes";
      } else if (buf.length > MAX_FILE_BYTES) {
        details["file.base64"] = `decoded size ${buf.length} exceeds ${MAX_FILE_BYTES} bytes`;
      }
    }
  }

  if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object") {
    details.raw_ocr = "must be an object";
  }

  if (Object.keys(details).length > 0) {
    return { ok: false, error: "validation_failed", details };
  }

  return {
    ok: true,
    payload: {
      po_number: (p.po_number as string).trim(),
      project_code: (p.project_code as string).trim(),
      supplier: (p.supplier as string).trim(),
      required_by_date: p.required_by_date as string | undefined,
      ordered_by: p.ordered_by as string | undefined,
      items: p.items as PurchaseOrderItemPayload[],
      file: p.file as PurchaseOrderIngestPayload["file"],
      raw_ocr: p.raw_ocr as Record<string, unknown>,
    },
  };
}

export type SiteMatchResult = {
  siteId: string | null;
  matched: boolean;
  warning?: "site_unmatched";
};

// Minimal interface so tests can pass a fake pb; production code uses pbAdmin().
type PbLike = {
  collection: (name: string) => {
    getFullList: (opts: { filter?: string }) => Promise<Array<{ id: string; project_code?: string }>>;
  };
};

export async function matchSite(pb: PbLike, projectCode: string): Promise<SiteMatchResult> {
  const safe = projectCode.replace(/"/g, '\\"');

  const exact = await pb.collection("sites").getFullList({
    filter: `project_code = "${safe}"`,
  });
  if (exact.length > 0) {
    return { siteId: exact[0].id, matched: true };
  }

  const fuzzy = await pb.collection("sites").getFullList({
    filter: `project_code ~ "${safe}"`,
  });
  if (fuzzy.length > 0) {
    const sorted = [...fuzzy].sort(
      (a, b) => (a.project_code?.length ?? 0) - (b.project_code?.length ?? 0),
    );
    return { siteId: sorted[0].id, matched: true };
  }

  return { siteId: null, matched: false, warning: "site_unmatched" };
}

export function computeTotalValue(items: PurchaseOrderItemPayload[]): number {
  const total = items.reduce((sum, it) => {
    const disc = 1 - (it.disc_percent ?? 0) / 100;
    const vat = 1 + (it.vat_percent ?? 0) / 100;
    return sum + it.qty * it.rate * disc * vat;
  }, 0);
  return Math.round(total * 100) / 100;
}
  • Step 4: Run tests to verify they pass

Run: npm run test:run Expected: All tests PASS. Suite summary shows validatePurchaseOrderPayload, computeTotalValue, matchSite all green.

  • Step 5: Typecheck

Run: npm run typecheck Expected: exit 0, no TS errors.

  • Step 6: Commit
git add src/lib/purchase-order-schema.ts src/lib/purchase-order-schema.test.ts
git commit -m "Add PO payload schema, validator, site-matching, total-value helper"

Task 3: Upload route — /api/purchase-orders/upload

Files:

  • Create: src/app/api/purchase-orders/upload/route.ts

  • Step 1: Implement the route

Create src/app/api/purchase-orders/upload/route.ts:

import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

const ALLOWED_MIME = new Set([
  "application/pdf",
  "image/jpeg",
  "image/png",
  "image/webp",
]);
const MAX_FILE_BYTES = 20 * 1024 * 1024;

export async function POST(request: Request) {
  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 n8nUrl = process.env.N8N_PO_INGEST_URL;
  if (!n8nUrl) {
    console.error("[po-upload] N8N_PO_INGEST_URL not configured");
    return NextResponse.json({ error: "n8n_unconfigured" }, { status: 500 });
  }

  // Forward to n8n as multipart
  const n8nForm = new FormData();
  n8nForm.append("file", file, file.name);
  n8nForm.append("filename", file.name);
  n8nForm.append("contentType", file.type);
  n8nForm.append("size", String(file.size));

  let n8nRes: Response;
  try {
    n8nRes = await fetch(n8nUrl, { method: "POST", body: n8nForm });
  } catch (err) {
    console.error("[po-upload] n8n unreachable", {
      filename: file.name,
      err: String(err),
    });
    return NextResponse.json({ error: "n8n_unreachable" }, { status: 502 });
  }

  console.log("[po-upload] forwarded", {
    filename: file.name,
    size: file.size,
    contentType: file.type,
    n8n_status: n8nRes.status,
    duration_ms: Date.now() - start,
  });

  return NextResponse.json(
    { status: "processing", filename: file.name },
    { status: 202 },
  );
}
  • Step 2: Typecheck

Run: npm run typecheck Expected: exit 0.

  • Step 3: Smoke test (manual)

Start the dev server in another terminal: npm run dev (it runs on port 6004).

Then:

# 1) Missing file -> 400
curl -i -X POST http://localhost:6004/api/purchase-orders/upload

# 2) Wrong type -> 400
echo "x" > /tmp/test.txt
curl -i -X POST http://localhost:6004/api/purchase-orders/upload \
  -F "file=@/tmp/test.txt;type=text/plain"

# 3) Valid file, n8n env not set -> 500 with error "n8n_unconfigured"
echo "x" > /tmp/test.pdf
curl -i -X POST http://localhost:6004/api/purchase-orders/upload \
  -F "file=@/tmp/test.pdf;type=application/pdf"

Expected status codes: 400, 400, 500 respectively. Stop the dev server.

  • Step 4: Commit
git add src/app/api/purchase-orders/upload/route.ts
git commit -m "Add /api/purchase-orders/upload route (forwards file to n8n)"

Task 4: Ingest route — /api/purchase-orders/ingest

Files:

  • Create: src/app/api/purchase-orders/ingest/route.ts

  • Step 1: Implement the route

Create src/app/api/purchase-orders/ingest/route.ts:

import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import {
  validatePurchaseOrderPayload,
  matchSite,
  computeTotalValue,
} from "@/lib/purchase-order-schema";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function POST(request: Request) {
  const start = Date.now();

  // 1. Auth
  const secret = request.headers.get("x-ingest-secret");
  if (!secret || secret !== process.env.N8N_INGEST_SECRET) {
    console.warn("[po-ingest] unauthorized");
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  // 2. Parse JSON
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }

  // 3. Validate
  const v = validatePurchaseOrderPayload(body);
  if (!v.ok) {
    console.warn("[po-ingest] validation_failed", { details: v.details });
    return NextResponse.json(
      { error: v.error, details: v.details },
      { status: 400 },
    );
  }
  const payload = v.payload;

  // 4. Decode file
  let fileBuffer: Buffer;
  try {
    fileBuffer = Buffer.from(payload.file.base64, "base64");
  } catch (err) {
    console.error("[po-ingest] decode_file failed", { err: String(err) });
    return NextResponse.json(
      { error: "decode_failed", step: "decode_file" },
      { status: 500 },
    );
  }

  const pb = await pbAdmin();

  // 5. Idempotency: check for existing po_number
  try {
    const existing = await pb.collection("purchase_orders").getFullList({
      filter: `purchase_order_number = "${payload.po_number.replace(/"/g, '\\"')}"`,
    });
    if (existing.length > 0) {
      return NextResponse.json(
        { error: "duplicate_po_number", existing_id: existing[0].id },
        { status: 409 },
      );
    }
  } catch (err) {
    console.error("[po-ingest] lookup_duplicate failed", { err: String(err) });
    return NextResponse.json(
      { error: "lookup_failed", step: "lookup_duplicate" },
      { status: 500 },
    );
  }

  // 6. Match site
  const warnings: string[] = [];
  let siteId: string | null = null;
  try {
    const match = await matchSite(pb, payload.project_code);
    siteId = match.siteId;
    if (match.warning) warnings.push(match.warning);
  } catch (err) {
    console.error("[po-ingest] lookup_site failed", { err: String(err) });
    return NextResponse.json(
      { error: "lookup_failed", step: "lookup_site" },
      { status: 500 },
    );
  }

  // 7. Create PO record (with file + denormalized fields)
  let poRecord: { id: string };
  try {
    const fileForPb = new File(
      [fileBuffer],
      payload.file.filename,
      { type: payload.file.contentType },
    );

    poRecord = await pb.collection("purchase_orders").create({
      purchase_order_number: payload.po_number,
      project_code: payload.project_code,
      supplier: payload.supplier,
      required_by_date: payload.required_by_date ?? null,
      ordered_by: payload.ordered_by ?? "",
      items_count: payload.items.length,
      total_value: computeTotalValue(payload.items),
      purchase_order_file: fileForPb,
      raw_ocr_data: {
        ...payload.raw_ocr,
        ingested_at: new Date().toISOString(),
        matched_site_id: siteId,
        unmatched_project_code: !siteId,
      },
      is_verified: false,
      site: siteId,
    });
  } catch (err) {
    console.error("[po-ingest] create_po failed", {
      err: String(err),
      po_number: payload.po_number,
    });
    return NextResponse.json(
      { error: "create_failed", step: "create_po" },
      { status: 500 },
    );
  }

  // 8. Create line items (sequential — PocketBase SDK has no batch insert)
  try {
    for (const item of payload.items) {
      const disc = 1 - (item.disc_percent ?? 0) / 100;
      const vat = 1 + (item.vat_percent ?? 0) / 100;
      const amount = Math.round(item.qty * item.rate * disc * vat * 100) / 100;
      await pb.collection("purchase_order_items").create({
        purchase_order: poRecord.id,
        material_code: item.material_code ?? "",
        cost_code: item.cost_code ?? "",
        description: item.description,
        qty: item.qty,
        qty_outstanding: item.qty,
        unit: item.unit ?? "",
        rate: item.rate,
        disc_percent: item.disc_percent ?? 0,
        vat_percent: item.vat_percent ?? 0,
        amount,
      });
    }
  } catch (err) {
    console.error("[po-ingest] create_items failed", {
      err: String(err),
      po_id: poRecord.id,
    });
    return NextResponse.json(
      { error: "create_failed", step: "create_items" },
      { status: 500 },
    );
  }

  console.log("[po-ingest] ok", {
    po_number: payload.po_number,
    project_code: payload.project_code,
    matched_site_id: siteId,
    items_count: payload.items.length,
    total_value: computeTotalValue(payload.items),
    duration_ms: Date.now() - start,
    warnings,
  });

  return NextResponse.json(
    {
      ok: true,
      id: poRecord.id,
      po_number: payload.po_number,
      warnings: warnings.length > 0 ? warnings : undefined,
    },
    { status: 200 },
  );
}
  • Step 2: Typecheck

Run: npm run typecheck Expected: exit 0.

  • Step 3: Smoke test — unauthorized (manual)

Start the dev server: npm run dev (port 6004).

curl -i -X POST http://localhost:6004/api/purchase-orders/ingest \
  -H "Content-Type: application/json" \
  -d '{}'

Expected: HTTP/1.1 401 with body {"error":"unauthorized"}.

  • Step 4: Smoke test — validation failure (manual)

Set a temporary secret in .env.local (do not commit):

N8N_INGEST_SECRET=test-secret-xyz

Restart dev server. Then:

curl -i -X POST http://localhost:6004/api/purchase-orders/ingest \
  -H "Content-Type: application/json" \
  -H "x-ingest-secret: test-secret-xyz" \
  -d '{"po_number":"PO-1","project_code":"X","supplier":"Y","items":[],"file":{"filename":"x","contentType":"application/pdf","base64":""},"raw_ocr":{}}'

Expected: HTTP/1.1 400 with details.items populated.

  • Step 5: Stop dev server, remove the test secret
# Stop dev server (Ctrl+C in its terminal)
# Remove the test N8N_INGEST_SECRET line from .env.local
  • Step 6: Commit
git add src/app/api/purchase-orders/ingest/route.ts
git commit -m "Add /api/purchase-orders/ingest route (n8n callback, writes to PB)"

Task 5: Upload button component

Files:

  • Create: src/components/upload-purchase-order-button.tsx

  • Step 1: Implement the component

Create src/components/upload-purchase-order-button.tsx:

"use client";

import * as React from "react";
import { FileText, Loader2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";

type Phase = "idle" | "uploading" | "done" | "error";

interface UploadAck {
  status: "processing";
  filename: string;
}

const ALLOWED_MIMES = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "application/pdf",
];
const ACCEPT_ATTR = ".jpg,.jpeg,.png,.webp,.pdf";
const MAX_FILE_BYTES = 20 * 1024 * 1024;

export function UploadPurchaseOrderButton() {
  const [open, setOpen] = React.useState(false);
  const [file, setFile] = React.useState<File | null>(null);
  const [phase, setPhase] = React.useState<Phase>("idle");
  const [result, setResult] = React.useState<UploadAck | null>(null);
  const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const reset = React.useCallback(() => {
    setFile(null);
    setPhase("idle");
    setResult(null);
    setErrorMsg(null);
    if (inputRef.current) inputRef.current.value = "";
  }, []);

  const close = () => {
    setOpen(false);
    setTimeout(reset, 200);
  };

  const submit = async () => {
    if (!file) return;
    setPhase("uploading");
    setErrorMsg(null);
    try {
      const form = new FormData();
      form.append("file", file);
      const res = await fetch("/api/purchase-orders/upload", {
        method: "POST",
        body: form,
      });
      const data = await res.json();
      if (!res.ok) {
        setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed");
        setPhase("error");
        return;
      }
      setResult(data);
      setPhase("done");
    } catch (e) {
      setErrorMsg(e instanceof Error ? e.message : "Upload failed");
      setPhase("error");
    }
  };

  const onPickFile: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    if (!ALLOWED_MIMES.includes(f.type)) {
      setErrorMsg("Unsupported file type: " + (f.type || "unknown"));
      setFile(null);
      return;
    }
    if (f.size > MAX_FILE_BYTES) {
      setErrorMsg("File too large — max 20 MB");
      setFile(null);
      return;
    }
    setErrorMsg(null);
    setFile(f);
  };

  return (
    <>
      <Button onClick={() => setOpen(true)} className="gap-2">
        <Upload className="h-4 w-4" />
        Upload purchase order
      </Button>

      <Dialog
        open={open}
        onOpenChange={(o) => (o ? setOpen(true) : close())}
      >
        <DialogContent className="max-w-xl">
          <DialogHeader>
            <DialogTitle>Upload purchase order</DialogTitle>
            <DialogDescription>
              Attach the PO document. n8n will extract the header fields and
              line items, then add the record to your purchase order list.
            </DialogDescription>
          </DialogHeader>

          {phase === "done" && result ? (
            <DonePanel result={result} onClose={close} />
          ) : (
            <div className="space-y-4">
              <label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-muted/20 px-4 py-8 text-center transition-colors hover:border-[#F2B165]/60 hover:bg-[#F2B165]/5">
                <input
                  ref={inputRef}
                  type="file"
                  accept={ACCEPT_ATTR}
                  className="sr-only"
                  onChange={onPickFile}
                />
                {file ? (
                  <>
                    <FileText className="h-7 w-7 text-[#F2B165]" />
                    <p className="text-sm font-medium">{file.name}</p>
                    <p className="text-xs text-muted-foreground">
                      {(file.size / 1024).toFixed(1)} KB - {file.type}
                    </p>
                    <button
                      type="button"
                      onClick={(e) => {
                        e.preventDefault();
                        setFile(null);
                        if (inputRef.current) inputRef.current.value = "";
                      }}
                      className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
                    >
                      <X className="h-3 w-3" /> Choose a different file
                    </button>
                  </>
                ) : (
                  <>
                    <Upload className="h-7 w-7 text-muted-foreground" />
                    <p className="text-sm font-medium">
                      Click to choose a PO document
                    </p>
                    <p className="text-xs text-muted-foreground">
                      JPG, PNG, WebP, or PDF - up to 20 MB
                    </p>
                  </>
                )}
              </label>

              {errorMsg && (
                <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
                  {errorMsg}
                </div>
              )}

              <div className="flex items-center justify-between gap-2 border-t border-border pt-3">
                <p className="text-xs text-muted-foreground">
                  Records start as <span className="font-mono">unverified</span> - approve from the PO row after review.
                </p>
                <div className="flex gap-2">
                  <Button variant="ghost" onClick={close}>
                    Cancel
                  </Button>
                  <Button
                    onClick={submit}
                    disabled={!file || phase === "uploading"}
                    className="gap-2"
                  >
                    {phase === "uploading" ? (
                      <>
                        <Loader2 className="h-4 w-4 animate-spin" />
                        Uploading…
                      </>
                    ) : (
                      <>
                        <Upload className="h-4 w-4" />
                        Upload
                      </>
                    )}
                  </Button>
                </div>
              </div>
            </div>
          )}
        </DialogContent>
      </Dialog>
    </>
  );
}

function DonePanel({
  result,
  onClose,
}: {
  result: UploadAck;
  onClose: () => void;
}) {
  return (
    <div className="space-y-4">
      <div className="rounded-md border border-[#F2B165]/30 bg-[#F2B165]/10 p-4 text-sm">
        <p className="font-medium">Uploaded - processing in n8n</p>
        <p className="mt-1 text-xs text-muted-foreground">
          <span className="font-mono text-foreground">{result.filename}</span> has been forwarded to the OCR pipeline.
        </p>
        <p className="mt-2 text-xs">
          The PO will appear in the list below once extraction finishes. Refresh the page if you've been away.
        </p>
      </div>
      <div className="flex justify-end border-t border-border pt-3">
        <Button onClick={onClose}>Done</Button>
      </div>
    </div>
  );
}
  • Step 2: Typecheck

Run: npm run typecheck Expected: exit 0.

  • Step 3: Commit
git add src/components/upload-purchase-order-button.tsx
git commit -m "Add UploadPurchaseOrderButton component"

Task 6: Wire button into the Purchasing topbar

Files:

  • Modify: src/app/dashboard/purchasing/page.tsx

  • Step 1: Add the import and render the button

Open src/app/dashboard/purchasing/page.tsx. Make two changes:

  1. After the existing import of UploadDeliveryNoteButton (line 4), add:
import { UploadPurchaseOrderButton } from "@/components/upload-purchase-order-button";
  1. In the <Topbar ...> JSX (around line 102), change the right={...} prop from:
        right={<UploadDeliveryNoteButton purchaseOrders={poOptions} />}

to:

        right={
          <div className="flex flex-wrap items-center gap-2">
            <UploadPurchaseOrderButton />
            <UploadDeliveryNoteButton purchaseOrders={poOptions} />
          </div>
        }
  • Step 2: Typecheck

Run: npm run typecheck Expected: exit 0.

  • Step 3: Commit
git add src/app/dashboard/purchasing/page.tsx
git commit -m "Mount UploadPurchaseOrderButton on /dashboard/purchasing"

Task 7: End-to-end smoke test (manual, post-deploy)

Files: none — manual verification only.

  • Step 1: Configure real secrets in .env.local
# Generate a real secret
openssl rand -hex 32

# In .env.local, set:
N8N_PO_INGEST_URL=https://<your-n8n-host>/webhook/purchase-order-upload
N8N_INGEST_SECRET=<paste-the-generated-secret>

Do not commit .env.local.

  • Step 2: Build the n8n workflow

Follow the handoff in docs/n8n/purchase-order-ingest.md:

  1. Create the webhook trigger (POST /purchase-order-upload).
  2. Branch on contentType: PDF → pdf-parse, image → Tesseract.
  3. Parse header + items with regex or an LLM node.
  4. Encode the original file to base64 and POST to ${APP_URL}/api/purchase-orders/ingest with header x-ingest-secret: ${N8N_INGEST_SECRET}.
  5. Wire the retry-on-500 policy (3× backoff 10s/30s/90s).
  6. Activate the workflow.
  • Step 3: Start the app and verify the button renders
npm run dev

Open http://localhost:6004/dashboard/purchasing (after PIN-gate). Confirm:

  • "Upload purchase order" button appears in the topbar.

  • "Upload delivery note" button still appears next to it.

  • Clicking the PO button opens the dialog with the drop zone.

  • Step 4: Test upload with a real PO file

  1. Pick a PDF PO file. Click the upload button. Submit.
  2. Confirm the success panel reads "Uploaded - processing in n8n".
  3. In n8n's execution log, confirm the webhook fired and the OCR + parse steps ran.
  4. Confirm the callback POST to /api/purchase-orders/ingest returned 200.
  5. Reload /dashboard/purchasing. Confirm the new PO row appears with the right total_value and items_count.
  • Step 5: Test idempotency (duplicate upload)
  1. Click the button again, pick the same file, submit.
  2. n8n will re-process and POST the same po_number.
  3. Expect n8n to receive a 409 { error: "duplicate_po_number", existing_id: "..." } response.
  4. n8n's "Error" branch should log it. No new row in PocketBase.
  • Step 6: Test unmatched project code
  1. Upload a PO whose project_code doesn't match any site.
  2. Expect n8n to receive a 200 with warnings: ["site_unmatched"].
  3. Expect a new PO in PocketBase with site empty and raw_ocr_data.unmatched_project_code = true.
  • Step 7: Test bad secret (regression)
curl -i -X POST http://localhost:6004/api/purchase-orders/ingest \
  -H "Content-Type: application/json" \
  -H "x-ingest-secret: wrong-secret" \
  -d '{"po_number":"x"}'

Expected: HTTP/1.1 401.

  • Step 8: Final commit (if any docs/notes were added)

If you made tweaks during smoke testing:

git add -A
git commit -m "PO upload + n8n ingest: smoke-tested end to end"

Self-Review Notes

After writing this plan I verified:

  1. Spec coverage — every section of the spec maps to a task:

    • Schema / payload contract → Task 2
    • Site matching logic → Task 2 (matchSite) + Task 4 (calls it)
    • Upload route (forwards file, returns 202) → Task 3
    • Ingest route (secret, validation, PO + items write, idempotency, warnings) → Task 4
    • Upload button UI → Task 5
    • Mount in topbar → Task 6
    • Env vars → Task 1 (.env.local.example) + Task 7 (.env.local)
    • Observability logs → Tasks 3 & 4 (console.log with prefixes)
    • End-to-end smoke tests → Task 7
  2. Placeholder scan — no "TBD", "TODO", or vague "add error handling". Every code block contains the actual implementation.

  3. Type consistency —

    • validatePurchaseOrderPayload returns ValidationResult, used identically in Tasks 2 (test) and 4 (route).
    • matchSite signature (pb: PbLike, projectCode: string): Promise<SiteMatchResult> is the same in Task 2's tests and Task 4's call site.
    • computeTotalValue is called once in Task 4 (create_po) and its unit tests match.
    • PurchaseOrderIngestPayload is defined once in Task 2 and used by the test in Task 2; the route in Task 4 only consumes the validated payload (no type imports needed).
    • File path constants match: src/lib/purchase-order-schema.ts, src/app/api/purchase-orders/upload/route.ts, src/app/api/purchase-orders/ingest/route.ts, src/components/upload-purchase-order-button.tsx, src/app/dashboard/purchasing/page.tsx.
  4. One known limitation documented in the spec — first-match site resolution with multiple candidates is preserved.