/** * Migration — consolidate PO documents onto `purchase_orders`. * * Adds two fields to the `purchase_orders` collection: * - `documents` (file, multi) — the latest PO PDF + history. * - `documents_history` (json) — read-only audit log of * every regen (version, * generatedAt, generatedBy, * reason). * * Why consolidate: * Today the PO PDF lives in a separate `purchase_order_pdfs` collection * (one row per regen, status="current" flag for the latest). Every * Send PO flow does THREE PB round-trips to fetch the attachment: * 1. fetch PO * 2. fetch latest row from `purchase_order_pdfs` for that PO * 3. fetch the file bytes via the proxy * Consolidating onto `purchase_orders.documents` (file, multi) shrinks * this to TWO round-trips — and lets the webhook payload include the * filename directly without a separate query. `documents_history` * keeps the per-regen audit trail without a separate collection. * * Backfill: * For every PO that already has `purchase_order_pdfs` rows, copy: * - `documents` ← the file from the row with status="current" * (or the highest-version row if no "current") * - `documents_history` ← array of `{version, file, generatedAt, * generatedBy, reason}` for ALL rows * (ordered by version ASC), so future regens * preserve the audit trail. * * Idempotent — skips the field-add when fields already exist, and the * backfill is a no-op when documents is already populated. * * Run with: * npx tsx scripts/migrations/2026-07-06-purchase-orders-documents-field.ts * * Archive step (separate, run after this is verified): * After the dashboard reads from `documents` everywhere, mark the * `purchase_order_pdfs` collection `is_active=false` in PB admin so * new writes are blocked. The collection can stay around for audit * queries (the file proxy still whitelists it). */ /* eslint-disable */ 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 { /* fine */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); const PO_COLLECTION = "purchase_orders"; const PDF_COLLECTION = "purchase_order_pdfs"; interface PbField { name: string; type: string; required?: boolean; options?: Record; maxSelect?: number | null; } interface PdfRow { id: string; purchase_order: string; file: string; version: number; status: string; generated_at: string; generated_by: string; notes: string; } interface PoRow { id: string; documents?: string[]; documents_history?: Array<{ version: number; file: string; generatedAt: string; generatedBy: string; reason: string; status: string; }>; } 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.", ); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[po-documents] Authenticated against ${url}`); // ─── 1. Add fields to purchase_orders ───────────────────────────── const poCol = await pb.collections.getOne(PO_COLLECTION); const poFields = (poCol.fields as PbField[]).slice(); const additions: PbField[] = []; if (!poFields.some((f) => f.name === "documents")) { additions.push({ name: "documents", type: "file", required: false, options: { maxSelect: 20, maxSize: 10485760, // 10 MB per file — most PO PDFs are <500KB mimeTypes: ["application/pdf", "image/png", "image/jpeg"], // PB v0.22 needs these for file fields even though it's // conceptually `[]` (no thumbs) for headless server usage. thumbs: [], }, }); console.log("[po-documents] documents field: ADD"); } else { console.log("[po-documents] documents field: skip (already exists)"); } if (!poFields.some((f) => f.name === "documents_history")) { additions.push({ name: "documents_history", type: "json", required: false, options: { maxSize: 50000 }, }); console.log("[po-documents] documents_history field: ADD"); } else { console.log("[po-documents] documents_history field: skip (already exists)"); } if (additions.length > 0) { poFields.push(...additions); await pb.collections.update(poCol.id, { fields: poFields }); console.log( `[po-documents] purchase_orders — added ${additions.length} field(s)`, ); } // ─── 2. Backfill documents + documents_history from purchase_order_pdfs ─ // PB file fields don't accept plain filename strings — they need an // actual file upload via FormData. So for each PO with PDF rows we // need to: // (a) download each PDF's bytes from PB (admin client URL) // (b) re-upload to purchase_orders.documents via FormData // (c) populate documents_history with the metadata (no file, just JSON) const pdfList = await pb.collection(PDF_COLLECTION).getFullList({ sort: "purchase_order,version", }); console.log(`[po-documents] Found ${pdfList.length} purchase_order_pdfs rows`); // Group by purchase_order const byPo = new Map(); for (const row of pdfList) { const arr = byPo.get(row.purchase_order) ?? []; arr.push(row); byPo.set(row.purchase_order, arr); } console.log(`[po-documents] Grouped into ${byPo.size} PO(s)`); let migrated = 0; let skipped = 0; let failed = 0; for (const [poId, rows] of byPo) { try { // Skip if PO already has documents (re-run safety). const current = await pb .collection(PO_COLLECTION) .getOne(poId, { fields: "id,documents,documents_history" }); if ( Array.isArray(current.documents) && current.documents.length > 0 ) { skipped++; continue; } // Latest = status="current" first, else highest version. const sorted = rows.slice().sort((a, b) => { if (a.status === "current" && b.status !== "current") return -1; if (b.status === "current" && a.status !== "current") return 1; return b.version - a.version; }); const latest = sorted[0]; // Build the history array (asc by version — newest at the END). const history = rows .slice() .sort((a, b) => a.version - b.version) .map((r) => ({ version: r.version, file: r.file, generatedAt: r.generated_at || "", generatedBy: r.generated_by || "", reason: r.notes || "", status: r.status, })); // Download the latest PDF bytes via admin PB file URL. const downloadUrl = pb.files.getURL( { collectionId: PDF_COLLECTION, id: latest.id }, latest.file, ); const fileRes = await fetch(downloadUrl, { headers: { Authorization: pb.authStore.token ?? "" }, }); if (!fileRes.ok) { throw new Error( `PDF download failed: HTTP ${fileRes.status} on ${downloadUrl}`, ); } const fileBlob = await fileRes.blob(); // PB expects a File object, not a Blob — wrap with the original name. const file = new File([fileBlob], latest.file, { type: fileRes.headers.get("content-type") || "application/pdf", }); // Re-upload to purchase_orders.documents (file, multi — array of File). // We only migrate the LATEST file (history captures the rest as // metadata; if the operator ever wants to download an older // version, they still can via the legacy `purchase_order_pdfs` // collection until we archive it). const formData = new FormData(); formData.append("documents", file); const updated = await pb.collection(PO_COLLECTION).update( poId, formData, ); // PB appends a random suffix to file uploads to prevent // collisions (e.g. `foo.pdf` → `foo_uc56p4ttjl.pdf`). Read back // the actual stored filename from the response and update the // history entry to match — otherwise the download URL won't // resolve. Same applies to every entry's `file` field. const actualStoredFiles: string[] = Array.isArray(updated.documents) ? updated.documents : updated.documents ? [updated.documents] : []; const lastStoredFile = actualStoredFiles[actualStoredFiles.length - 1]; const syncedHistory = history.map((h, idx) => // The LAST history entry corresponds to the LAST stored file. // For pre-migration data we can only sync that one reliably — // older versions stay archived in the legacy collection. idx === history.length - 1 && lastStoredFile ? { ...h, file: lastStoredFile } : h, ); // Then write documents_history separately (it's JSON, not a file). await pb.collection(PO_COLLECTION).update(poId, { documents_history: syncedHistory, }); migrated++; console.log( `[po-documents] PO ${poId}: ${rows.length} PDF row(s) → documents=[${latest.file}] (${fileBlob.size}B), history[${history.length}]`, ); } catch (err) { failed++; console.warn(`[po-documents] PO ${poId}: failed to migrate:`, err); } } console.log( `[po-documents] Backfill complete — migrated=${migrated}, skipped=${skipped}, failed=${failed}`, ); console.log("[po-documents] Migration complete."); } main().catch((err) => { console.error("[po-documents] Migration failed:", err); process.exit(1); });