/* eslint-disable */ /** * Migration: convert three customer fields from plain text to proper * PB types. * * customers.company text → relation → companies * customers.rep text → relation → users * customers.coordinates text → geoPoint * * Why these changes * ────────────────── * A customer works for a company (organisation like Harmony Moab) and * may deliver to a different address per shaft. Today those three * fields are all free-text, which forces every consumer to either * render raw names or fuzzy-look-up a record by string match. * * company — make it a relation so we can fetch the company * record (address, vendor, future coordinates), so the * dropdown in the customer form shows real company * names, and so the customer's parent company survives * company renames. * rep — make it a relation to `users` so we can resolve the * sales rep's email / name without a separate lookup * table, and so the order form's rep auto-fill gets a * stable ID instead of a typo-prone free-text name. * coordinates — switch to native PB geoPoint so we get `within` * radius queries for the future delivery app, instead * of re-parsing a free-text "lat, lng" string every * time we need a distance. * * Why we read-then-transform-then-backfill * ───────────────────────────────────────── * PB's collection.update requires the FULL fields array — you can't * "rename" a field, only drop-and-add. Dropping the old text fields * would erase the data on the existing rows, so we: * * 1. Read every customer row + the candidate target records. * 2. Drop the old fields, add the new ones, save the collection. * 3. Walk the captured rows; for each legacy value that still has a * matching target record, set the new field to that record's id. * Anything that doesn't match is left null — operator re-links. * * Why fuzzy name match is acceptable for backfill * ─────────────────────────────────────────────── * The legacy `customer.company` text often holds the company name * verbatim ("Harmony Moab"), and legacy `customer.rep` holds the rep * name verbatim ("John Smith"). Match is case-insensitive trimmed * equality against `companies.company` and `users.name`. Misses are * harmless — operator re-links via the form's combobox. The migration * is idempotent: re-runs detect the new fields, skip the schema * rewrite, and re-attempt the backfill for any rows still missing * their relation. * * Run with: * npx tsx scripts/migrations/2026-06-28-convert-customer-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 { // missing file — fine } } 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) { throw new Error( "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. from .env.local)." ); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[customer-fields] Authenticated against ${url}`); // Look up the three collection IDs we need: customers (the one // we're rewriting), companies (target of the company relation), // and users (PB's auth collection — target of the rep relation). const all = await pb.collections.getFullList(); const byName = new Map(all.map((c) => [c.name, c])); const customers = byName.get("customers"); const companies = byName.get("companies"); const users = byName.get("users"); if (!customers) throw new Error("Required collection `customers` not found."); if (!companies) throw new Error("Required collection `companies` not found."); if (!users) throw new Error("Required collection `users` not found."); // ─── Step 1: read every customer row's legacy values BEFORE we // rewrite the schema. Once we drop the text fields this data is // gone, so capture it up front. console.log("[customer-fields] Reading existing customer rows..."); const allCustomers = await pb.collection("customers").getFullList(); console.log(`[customer-fields] ${allCustomers.length} rows.`); // Index target records by lowercased name for the backfill below. const allCompanies = await pb.collection("companies").getFullList(); const companiesByName = new Map(); // lowercased name → id for (const c of allCompanies) { const key = (c.company || "").trim().toLowerCase(); if (key) companiesByName.set(key, c.id); } const allUsers = await pb.collection("users").getFullList(); const usersByName = new Map(); // lowercased name → id const usersByEmail = new Map(); // lowercased email → id for (const u of allUsers) { const nameKey = (u.name || "").trim().toLowerCase(); if (nameKey) usersByName.set(nameKey, u.id); const emailKey = (u.email || "").trim().toLowerCase(); if (emailKey) usersByEmail.set(emailKey, u.id); } // ─── Step 2: rewrite the schema if it isn't already in the new shape. // We treat the schema as "converted" when the relation fields exist. // The geoPoint field alone isn't enough since the legacy text field // could share a name with it (PB allows drop+add of same name only // if not present, which we check via type field-by-field). const fields = customers.fields; const hasCompanyRelation = fields.some( (f) => f.name === "company" && f.type === "relation" ); const hasRepRelation = fields.some( (f) => f.name === "rep" && f.type === "relation" ); const hasCoordinatesGeoPoint = fields.some( (f) => f.name === "coordinates" && f.type === "geoPoint" ); if (!hasCompanyRelation || !hasRepRelation || !hasCoordinatesGeoPoint) { const newFields = fields // Drop the legacy text columns if they still exist. .filter( (f) => !( (f.name === "company" && f.type === "text") || (f.name === "rep" && f.type === "text") || (f.name === "coordinates" && f.type === "text") ) ); // Add the new fields only if not already present. if (!newFields.some((f) => f.name === "company")) { newFields.push({ name: "company", type: "relation", required: false, collectionId: companies.id, maxSelect: 1, presentable: false, cascadeDelete: false, }); } if (!newFields.some((f) => f.name === "rep")) { newFields.push({ name: "rep", type: "relation", required: false, collectionId: users.id, maxSelect: 1, presentable: false, cascadeDelete: false, }); } if (!newFields.some((f) => f.name === "coordinates")) { newFields.push({ name: "coordinates", type: "geoPoint", required: false, presentable: false, }); } await pb.collections.update(customers.id, { fields: newFields }); console.log( "[customer-fields] Rewrote customers schema: company → relation, rep → relation, coordinates → geoPoint." ); } else { console.log("[customer-fields] Schema already converted — skipping rewrite."); } // ─── Step 3: backfill. For each legacy value that resolves to a // target record id, write it to the new field. Idempotent — skip // rows that already have a value in the new field. // // `allCustomers` was fetched at the top of this script, BEFORE the // schema rewrite, so the JS objects still hold the original text // values for `company` / `rep` even after step 2 dropped those // columns. (Re-fetching here would give us empty defaults for the // dropped fields.) let backfilledCompany = 0; let backfilledRep = 0; let skippedAlreadySet = 0; let skippedNoMatch = 0; for (const row of allCustomers) { const updates: Record = {}; const legacyCompany = typeof row.company === "string" ? row.company : ""; if (legacyCompany) { const match = companiesByName.get(legacyCompany.trim().toLowerCase()); if (match) { updates.company = match; backfilledCompany++; } else { skippedNoMatch++; } } else { skippedAlreadySet++; } const legacyRep = typeof row.rep === "string" ? row.rep : ""; if (legacyRep) { const key = legacyRep.trim().toLowerCase(); // Prefer name match; fall back to email match (some legacy // rows hold the rep's email rather than name). const match = usersByName.get(key) || usersByEmail.get(key); if (match) { updates.rep = match; backfilledRep++; } } if (Object.keys(updates).length > 0) { await pb.collection("customers").update(row.id, updates); } } console.log( `[customer-fields] Backfill: ${backfilledCompany} company links, ${backfilledRep} rep links. ${skippedAlreadySet} rows already empty, ${skippedNoMatch} rows had text but no matching company.` ); console.log("[customer-fields] Migration complete."); } main().catch((err) => { console.error("[customer-fields] Migration failed:", err); process.exit(1); });