/* eslint-disable */ /** * Migration — add pricing-source fields to `order_items` and * repopulate `item_status` select options. * * Why: * The order process page needs to show the buyer which matched * quote line a cost came from, and to round-trip those values when * a price is copied from the quote (or re-priced from the catalogue). * Today those fields only live on `quote_items`. The four we're * adding to `order_items`: * * - sku text the supplier's SKU code (e.g. * "M20-HEX-40-G88") * - pricing_source text "quote" | "catalogue" | * "manual" — drives the * audit-trail chip on the * process page. * - matched_price_list_id text catalogue row id when sourced * from `price_list`. * - pricing_notes text free text from the operator * ("cost confirmed via * phone call 2026-07-06") * * Plus a fix-up: `item_status` was migrated to a select with empty * `values: []` at some point in the past, which means PB will * accept arbitrary string writes but the operator can no longer * see/filter the canonical stages. Repopulating the values list * restores both the dropdown UI and the canonical set. * * Idempotent — each step checks before mutating, so partial-success * recovery is safe. * * Run with: * npx tsx scripts/migrations/2026-07-06-order-items-pricing-fields.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 { /* fine — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); // Fields to add (text fields, all optional, no defaults). const NEW_TEXT_FIELDS = [ { name: "sku", type: "text", required: false, options: { max: 100 } }, { name: "pricing_source", type: "text", required: false, options: { max: 50 } }, { name: "matched_price_list_id", type: "text", required: false, options: { max: 50 } }, { name: "pricing_notes", type: "text", required: false, options: { max: 2000 } }, ] as const; // Canonical item_status options (PB Select requires non-empty values). // Mirrors what's in src/lib/types.ts union. const ITEM_STATUS_VALUES = [ "pending", "processed", "po_generated", "driver_pool", "collected", "assigned", "delivered", "cancelled", ]; 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. sourced from .env.local).", ); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[order-items-pricing-fields] Authenticated against ${url}`); const col = await pb.collections.getOne("order_items"); const fields = (col.fields as Array<{ name: string; type: string; required?: boolean; options?: Record; }>).slice(); // ─── Step 1: add the four new text fields ───────────────────────── let added = 0; for (const target of NEW_TEXT_FIELDS) { if (fields.some((f) => f.name === target.name)) { console.log( `[order-items-pricing-fields] ${target.name} already exists — skip`, ); continue; } fields.push({ ...target }); added++; } console.log( `[order-items-pricing-fields] New text fields added: ${added} of ${NEW_TEXT_FIELDS.length}`, ); // ─── Step 2: repopulate item_status select values ───────────────── let itemStatusTouched = false; const itemStatusIdx = fields.findIndex((f) => f.name === "item_status"); if (itemStatusIdx === -1) { console.warn( "[order-items-pricing-fields] No `item_status` field found — skipping select-values fixup (manual check needed).", ); } else { const f = fields[itemStatusIdx]; const opts = (f.options ?? {}) as Record; const values = Array.isArray(opts.values) ? (opts.values as unknown[]) : []; // Normalise: PB can accept either string[] or {value,label}[]; we // pick the simplest representation the admin UI renders cleanly. const existingStrings = values .map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? "")) .filter(Boolean); const missing = ITEM_STATUS_VALUES.filter( (v) => !existingStrings.includes(v), ); if (missing.length === 0) { console.log( `[order-items-pricing-fields] item_status already has all ${ITEM_STATUS_VALUES.length} canonical values — skip`, ); } else { fields[itemStatusIdx] = { ...f, required: true, options: { ...opts, values: ITEM_STATUS_VALUES, }, }; itemStatusTouched = true; console.log( `[order-items-pricing-fields] item_status: added ${missing.length} missing values (${missing.join(", ")})`, ); } } if (added === 0 && !itemStatusTouched) { console.log( "[order-items-pricing-fields] No fields needed updating — schema is already current.", ); return; } await pb.collections.update(col.id, { fields }); console.log("[order-items-pricing-fields] Schema updated."); // Re-fetch + verify const fresh = await pb.collections.getOne("order_items"); const verifyFields = (fresh.fields as Array<{ name: string; type: string; required?: boolean; options?: { values?: unknown[] }; }>) .filter((f) => NEW_TEXT_FIELDS.some((t) => t.name === f.name) || f.name === "item_status", ) .map((f) => { if (f.name === "item_status") { const vals = Array.isArray(f.options?.values) ? f.options!.values .map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? "")) .filter(Boolean) : []; return `${f.name}:${vals.length} values`; } return `${f.name}:${f.type}`; }) .join(", "); console.log(`[order-items-pricing-fields] Post-update → ${verifyFields}`); console.log("[order-items-pricing-fields] Migration complete."); } main().catch((err) => { console.error("[order-items-pricing-fields] Migration failed:", err); process.exit(1); });