WFDASH-WEBAPP/scripts/migrations/2026-06-28-convert-customer-fields.ts
SJCoetz 6c07a91693 refactor: drop order-level status, shift to item-driven workflow
This commit overhauls the order management system to use item-level status tracking instead of a single order-level status:
- Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields
- Add `isOrderComplete` utility with tests to validate fully delivered orders
- Add purchase order PDF rendering support and tooling
- Add geo coordinate fields for companies/customers with map link UI
- Add REST management APIs for customers, companies, and suppliers
- Update customer/company services to use relation fields instead of free text
- Add status pill and tab filter components for item status visualization
- Add quote-to-order matching logic and unit tests
- Update all pages, components, and tests to match the new schema
- Include all required PocketBase migrations and schema updates
2026-06-29 13:06:01 +02:00

253 lines
No EOL
10 KiB
TypeScript

/* 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<string, string>(); // 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<string, string>(); // lowercased name → id
const usersByEmail = new Map<string, string>(); // 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<string, unknown> = {};
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);
});