/* eslint-disable */ /** * Migration — make `order_items.quantity` and `order_items.selling_price` * nullable. * * Why: * The /orders/new page and the /orders/[id]/process page bind these * fields to elements. Number inputs have two * operator-hostile behaviours: * * 1. They can't be left empty by the user — clearing them snaps * the value back to the previous number, or 0. * 2. They commit a value on focus→blur even when the operator * didn't type anything. * * The fix is to bind the fields to * elements (so the field is genuinely empty until the operator types), * parse to number|null on blur, and ship null to PB when the field is * blank. For null to round-trip, the PB columns must be nullable. * * Scope: * - `order_items.quantity` → number, not required, no min. * - `order_items.selling_price` → number, not required, no min. * * `cost_price` is intentionally left NON-nullable. It has a different * default (0) and is the operator's authoritative number — leaving it * nullable introduces a "what does a null cost mean?" ambiguity that * the matcher / PO generator cannot resolve without an extra decision. * If that ever changes, get a sign-off first. * * Idempotent — re-running after a partial success is safe (each field * is rewritten with the target shape; PB's collections.update is * idempotent when the resulting array matches the target). * * Run with: * npx tsx scripts/migrations/2026-07-06-order-items-nullable-numbers.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // ─── env loading (matches sibling migrations) ─────────────────────────── 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"); const TARGET_NULLABLE_FIELDS = ["quantity", "selling_price"] as const; 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-nullable] 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; }>; // Walk every field we want to be nullable, rewrite its `required` // flag, and reassemble. We also tighten `options.min` if any leak // remains (PB will keep prior min from earlier schema changes). let touched = 0; const updatedFields = fields.map((f) => { if ( f.type === "number" && TARGET_NULLABLE_FIELDS.includes(f.name as (typeof TARGET_NULLABLE_FIELDS)[number]) ) { const prevRequired = f.required !== false; if (!prevRequired) { console.log(`[order-items-nullable] ${f.name} already nullable — skip`); return f; } touched++; const opts = { ...(f.options ?? {}) }; // Drop any `min` constraint — null must round-trip cleanly. The // canSubmit gate on the form keeps zero-blank invariants honest. delete (opts as Record).min; return { ...f, required: false, options: opts }; } return f; }); if (touched === 0) { console.log( "[order-items-nullable] No fields needed updating — schema is already nullable.", ); return; } await pb.collections.update(col.id, { fields: updatedFields }); console.log( `[order-items-nullable] Made nullable: ${TARGET_NULLABLE_FIELDS.join(", ")}. Verify in UI by saving an order item with quantity = null.`, ); // Re-fetch so we can print the post-update shape for the operator. const fresh = await pb.collections.getOne("order_items"); const verify = (fresh.fields as Array<{ name: string; required?: boolean }>) .filter((f) => TARGET_NULLABLE_FIELDS.includes(f.name as (typeof TARGET_NULLABLE_FIELDS)[number])) .map((f) => `${f.name}:required=${f.required !== false ? "true" : "false"}`) .join(", "); console.log(`[order-items-nullable] Post-update → ${verify}`); console.log("[order-items-nullable] Migration complete."); } main().catch((err) => { console.error("[order-items-nullable] Migration failed:", err); process.exit(1); });