/* 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);
});