40 KiB
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, returns202.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 mirroringupload-delivery-note-button.tsx.
Modified files (3):
src/app/dashboard/purchasing/page.tsx— mount<UploadPurchaseOrderButton />next to<UploadDeliveryNoteButton />in the Topbar'srightslot..env.local.example— addN8N_PO_INGEST_URLandN8N_INGEST_SECRET.package.json— add Vitest dev-dep +testscript.
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
testscript andtest:runscript 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.tsat 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:
- After the existing import of
UploadDeliveryNoteButton(line 4), add:
import { UploadPurchaseOrderButton } from "@/components/upload-purchase-order-button";
- In the
<Topbar ...>JSX (around line 102), change theright={...}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:
- Create the webhook trigger (
POST /purchase-order-upload). - Branch on
contentType: PDF →pdf-parse, image → Tesseract. - Parse header + items with regex or an LLM node.
- Encode the original file to base64 and POST to
${APP_URL}/api/purchase-orders/ingestwith headerx-ingest-secret: ${N8N_INGEST_SECRET}. - Wire the retry-on-500 policy (3× backoff 10s/30s/90s).
- 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
- Pick a PDF PO file. Click the upload button. Submit.
- Confirm the success panel reads "Uploaded - processing in n8n".
- In n8n's execution log, confirm the webhook fired and the OCR + parse steps ran.
- Confirm the callback POST to
/api/purchase-orders/ingestreturned200. - Reload
/dashboard/purchasing. Confirm the new PO row appears with the righttotal_valueanditems_count.
- Step 5: Test idempotency (duplicate upload)
- Click the button again, pick the same file, submit.
- n8n will re-process and POST the same
po_number. - Expect n8n to receive a
409 { error: "duplicate_po_number", existing_id: "..." }response. - n8n's "Error" branch should log it. No new row in PocketBase.
- Step 6: Test unmatched project code
- Upload a PO whose
project_codedoesn't match any site. - Expect n8n to receive a
200withwarnings: ["site_unmatched"]. - Expect a new PO in PocketBase with
siteempty andraw_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:
-
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
-
Placeholder scan — no "TBD", "TODO", or vague "add error handling". Every code block contains the actual implementation.
-
Type consistency —
validatePurchaseOrderPayloadreturnsValidationResult, used identically in Tasks 2 (test) and 4 (route).matchSitesignature(pb: PbLike, projectCode: string): Promise<SiteMatchResult>is the same in Task 2's tests and Task 4's call site.computeTotalValueis called once in Task 4 (create_po) and its unit tests match.PurchaseOrderIngestPayloadis defined once in Task 2 and used by the test in Task 2; the route in Task 4 only consumes the validatedpayload(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.
-
One known limitation documented in the spec — first-match site resolution with multiple candidates is preserved.