/* eslint-disable */ /** * Migration: add `purchase_order_pdfs` collection for PO PDF versioning. * * Why this exists * ──────────────── * When we send a PO we now generate a real PDF and attach it to the * order in PocketBase. If the operator later edits a price / supplier / * quantity on a PO line, they need to regenerate the PDF — but the old * PDF must stay (audit trail + "what did we originally send the * supplier?"). So one PO → many PDF versions, each stored as its own * row in this collection. * * Schema * ────── * purchase_order relation → purchase_orders (required) * version number (required, ≥ 1) * file file (PDF, max 1) (required) * generated_at date (auto) * generated_by relation → users (optional, set by API) * notes text (optional, ≤ 500) ("re-priced after discount") * total_amount number (snapshot) (locked at gen time) * status select: current | superseded (only one "current" per PO) * * Version numbering is computed by the API route (count of existing * PDFs for this PO + 1) — not via a PB index — so two concurrent * regenerates can't collide on a unique constraint we don't have. The * server picks `status: current` for the new version and marks the * previous current as `superseded` in the same call. * * Idempotent — safe to re-run. * * Run with: * npx tsx scripts/migrations/2026-06-28-add-purchase-order-pdfs.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch { // missing file — fine } } loadEnvFile(".env"); loadEnvFile(".env.local"); async function main() { const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; const email = process.env.PB_ADMIN_EMAIL; const password = process.env.PB_ADMIN_PASSWORD; if (!url || !email || !password) { throw new Error( "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. from .env.local)." ); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[purchase-order-pdfs] Authenticated against ${url}`); const existing = await pb.collections.getFullList({ filter: "name = 'purchase_order_pdfs'" }); if (existing.length > 0) { console.log("[purchase-order-pdfs] purchase_order_pdfs already exists — skipping create."); return; } // Look up related collection IDs by name so we don't hard-code the // 15-char ids from the schema JSON (those can drift if the schema // is rebuilt on a fresh DB). const all = await pb.collections.getFullList(); const byName = new Map(all.map((c) => [c.name, c])); const purchaseOrders = byName.get("purchase_orders"); if (!purchaseOrders) { throw new Error("Required collection `purchase_orders` not found. Run prior migrations first."); } const created = await pb.collections.create({ name: "purchase_order_pdfs", type: "base", // Auth gate identical to peer collections: anyone signed in can // read/write. Tighten later if we expose PO PDFs externally. listRule: "@request.auth.id != ''", viewRule: "@request.auth.id != ''", createRule: "@request.auth.id != ''", updateRule: "@request.auth.id != ''", deleteRule: "@request.auth.id != ''", fields: [ { // FK to the PO this PDF version belongs to. cascadeDelete: true, collectionId: purchaseOrders.id, hidden: false, maxSelect: 1, minSelect: 1, name: "purchase_order", presentable: false, required: true, system: false, type: "relation", }, { // Auto-incremented at API write time. Stored as a number so // we can sort `version DESC` to find the latest cheaply. hidden: false, max: null, min: 1, name: "version", onlyInt: true, presentable: false, required: true, system: false, type: "number", }, { // The actual PDF binary. PB file fields support maxSelect: 1. // We use a generous max size — typical PO PDF is < 1 MB. hidden: false, maxSelect: 1, maxSize: 10485760, // 10 MB minSelect: 1, name: "file", presentable: false, required: true, system: false, type: "file", }, { // Generation timestamp. Drives default sort + UI display. hidden: false, max: "", min: "", name: "generated_at", presentable: false, required: false, system: false, type: "date", }, { // Optional FK to the user who triggered generation/regen. // Optional because some generations may be triggered by an // automated job (n8n workflow, cron) where the user is null. cascadeDelete: false, collectionId: "_pb_users_auth_", hidden: false, maxSelect: 1, minSelect: 0, name: "generated_by", presentable: false, required: false, system: false, type: "relation", }, { // Reason for regeneration. Optional on v1 (first send rarely // needs a note), common on v2+ ("re-priced after discount // discussion with supplier"). Capped at 500 chars. hidden: false, max: 500, min: 0, name: "notes", presentable: false, required: false, system: false, type: "text", }, { // Locked total at time of generation. Lets the UI show "this // PO was sent for R 4 250, v2 for R 4 100" without recomputing // from items (which may themselves have been edited later). hidden: false, max: null, min: null, name: "total_amount", onlyInt: false, presentable: false, required: false, system: false, type: "number", }, { // Only one "current" version per PO at any time. Old current // rows are flipped to "superseded" in the same API call that // creates the new current row. hidden: false, maxSelect: 1, name: "status", presentable: false, required: false, system: false, type: "select", values: ["current", "superseded"], }, ], indexes: [ // Fast "latest version for PO X" lookup: // filter: purchase_order = X // sort: -version "CREATE INDEX idx_popop_purchase_order_version ON purchase_order_pdfs (purchase_order, version DESC)", // Fast "what's the current PDF for PO X?" lookup: "CREATE INDEX idx_popop_status ON purchase_order_pdfs (status)", ], }); console.log( `[purchase-order-pdfs] Created collection ${created.name} (${created.id})` ); console.log("[purchase-order-pdfs] Migration complete."); } main().catch((err) => { console.error("[purchase-order-pdfs] Migration failed:", err); process.exit(1); });