/* eslint-disable */ /** * Migration: add v5 quote fields to the `quotes` collection. * * Idempotent — running twice is safe. Each step checks whether the * field already exists (by `name`) before adding it; for the `status` * select field, it merges "template" into the existing values array * without disturbing the rest. * * Required env (in .env or shell): * NEXT_PUBLIC_POCKETBASE_URL * PB_ADMIN_EMAIL * PB_ADMIN_PASSWORD * * Usage: npx tsx scripts/migrations/2026-06-23-add-quote-template-fields.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // Load .env / .env.local if present (tsx doesn't auto-load them) function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { // Skip comments and blank lines 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 { // file not present — fine, rely on shell env } } 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) { console.error("Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD"); process.exit(1); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); const QUOTES_COLLECTION_ID = "pbc_2527524235"; // from pb_schema (1).json const collection = await pb.collections.getOne(QUOTES_COLLECTION_ID); const fields = ((collection as { fields?: Array> }).fields ?? []) as Array< Record >; type FieldDef = Record; const newFields: FieldDef[] = [ { help: "Quote date printed on the PDF. If blank, the PDF falls back to quote.created.", hidden: false, id: "date_quote_date", max: "", min: "", name: "quote_date", presentable: false, required: false, system: false, type: "date", }, { help: "Validity period in days (e.g. 30, 60). If blank, defaults to 30.", hidden: false, id: "number_validity_days", max: null, min: null, name: "validity_days", onlyInt: true, presentable: false, required: false, system: false, type: "number", }, { help: "True if this quote is a reusable template (scaffolding, not a real customer quote).", hidden: false, id: "bool_is_template", name: "is_template", presentable: false, required: false, system: false, type: "bool", }, { cascadeDelete: false, collectionId: QUOTES_COLLECTION_ID, help: "If this template was cloned from a real quote, the source quote id.", hidden: false, id: "relation_template_source", maxSelect: 1, minSelect: 0, name: "template_source_id", presentable: false, required: false, system: false, type: "relation", }, { cascadeDelete: false, collectionId: QUOTES_COLLECTION_ID, help: "If this quote was generated from a template, the template quote id.", hidden: false, id: "relation_generated_from_template", maxSelect: 1, minSelect: 0, name: "generated_from_template_id", presentable: false, required: false, system: false, type: "relation", }, { help: "User-set follow-up reminder date. Dashboard widget shows quotes where follow_up_due <= today and follow-up is still pending.", hidden: false, id: "date_follow_up_due", max: "", min: "", name: "follow_up_due", presentable: false, required: false, system: false, type: "date", }, ]; const planned: string[] = []; for (const def of newFields) { const name = def.name as string; if (fields.some((f) => f.name === name)) { planned.push(`field "${name}" already present — skip`); } else { fields.push(def); planned.push(`add field "${name}"`); } } const statusField = fields.find((f) => f.name === "status"); if (statusField) { const values = (statusField.values as string[] | undefined) ?? []; if (values.includes("template")) { planned.push(`status already includes "template" — skip`); } else { statusField.values = [...values, "template"]; planned.push(`add "template" to status select values`); } } const willChange = planned.some((p) => !p.includes("skip")); if (!willChange) { console.log("Nothing to do — schema already up to date."); console.log("Verified fields:", fields.map((f) => f.name).join(", ")); return; } console.log("Planned changes:"); for (const p of planned) console.log(` • ${p}`); await pb.collections.update(QUOTES_COLLECTION_ID, { fields }); const after = await pb.collections.getOne(QUOTES_COLLECTION_ID); const afterFields = ((after as { fields?: Array> }).fields ?? []) as Array< Record >; const afterNames = afterFields.map((f) => f.name); const statusAfter = afterFields.find((f) => f.name === "status"); const statusVals = (statusAfter?.values as string[] | undefined) ?? []; console.log("\nMigration complete. Field names now present:"); console.log(" " + afterNames.join(", ")); console.log("\nSanity-check:"); console.log(" quote_date present: ", afterNames.includes("quote_date")); console.log(" validity_days present: ", afterNames.includes("validity_days")); console.log(" is_template present: ", afterNames.includes("is_template")); console.log(" template_source_id present: ", afterNames.includes("template_source_id")); console.log(" generated_from_template_id: ", afterNames.includes("generated_from_template_id")); console.log(" follow_up_due present: ", afterNames.includes("follow_up_due")); console.log(' status includes "template": ', statusVals.includes("template")); if ( !afterNames.includes("quote_date") || !afterNames.includes("validity_days") || !afterNames.includes("is_template") || !afterNames.includes("follow_up_due") || !statusVals.includes("template") ) { console.error("\n!!! Migration verification failed. Please inspect manually."); process.exit(1); } console.log("\nAll good. Reload the dashboard — the 400s should be gone."); } main().catch((err) => { console.error("Migration failed:", err); process.exit(1); });