/* eslint-disable */ /** * Migration — extend `quote_items` with the fields the new * "Check pricing" feature needs: * * 1. `sku` (text, optional, indexed) * What the buyer asked for. The Check-pricing button matches * this against `price_list.sku` (and the normalised * `price_list.sku_normalised`). For items extracted from email * the n8n Code node copies it in from the LLM-extracted * description. For manually-added items the operator types it * in the process page input. * * 2. `pricing_source` (select, optional) * Where the cost_price / selling_price values came from. Used * by the UI badge on the process page ("✓ from catalogue", * "○ no match", …). Free text for new sources we haven't * enumerated yet (v2 RFQ flow). * * 3. `matched_price_list_id` (text, optional) * The `price_list.id` row we matched against on the last * Check-pricing run. Lets us re-resolve the supplier / spec / * description without re-matching. Storing the id (not a * relation) keeps the schema simple — the helper handles the * resolution. * * 4. `pricing_notes` (text, optional) * Free-text audit trail. Examples: * "Auto-matched from price list (JEISA - WESTSCOTT GROUP) on 2026-06-30" * "No match in catalogue — operator to follow up" * "Catalogue had 3 candidates; picked newest" * Free-text (not a select) so the operator / AI can append * without a schema change. * * Idempotent — each step skips if its field already exists. * * Run with: * npx tsx scripts/migrations/2026-06-30-add-quote-items-sku-and-pricing.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 { /* 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) { 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(`[quote-items-sku] Authenticated against ${url}`); const quoteItemsCol = await pb.collections.getOne("quote_items"); const existingNames = new Set(quoteItemsCol.fields.map((f) => f.name)); const existingIndexes = (quoteItemsCol.indexes ?? []) as string[]; // Build the additive field list. Skip any fields that already // exist — this makes the migration safe to re-run after a partial // success on a previous attempt. const newFields: Array> = []; if (!existingNames.has("sku")) { newFields.push({ name: "sku", type: "text", required: false, presentable: false, options: { max: 100, pattern: "" }, }); } if (!existingNames.has("pricing_source")) { newFields.push({ name: "pricing_source", type: "text", required: false, presentable: false, // Free text rather than a select enum on purpose. The set of // sources may grow (catalogue, rfq, manual, rfq_pending, // catalogue_ambiguous in v2) and free text keeps the schema // forward-compatible without a migration per addition. The UI // badge maps known values to icons / colours and falls back to // a neutral chip for anything new. options: { max: 32, pattern: "" }, }); } if (!existingNames.has("matched_price_list_id")) { newFields.push({ name: "matched_price_list_id", type: "text", required: false, presentable: false, options: { max: 32, pattern: "" }, }); } if (!existingNames.has("pricing_notes")) { newFields.push({ name: "pricing_notes", type: "text", required: false, presentable: false, options: { max: 500, pattern: "" }, }); } if (newFields.length > 0) { await pb.collections.update(quoteItemsCol.id, { fields: [...quoteItemsCol.fields, ...newFields], }); console.log(`[quote-items-sku] Added fields: ${newFields.map((f) => f.name).join(", ")}.`); } else { console.log("[quote-items-sku] All pricing fields already present — skipping field add."); } // Re-fetch so we see the post-update indexes. const fresh = await pb.collections.getOne("quote_items"); const hasSkuIdx = (fresh.indexes ?? []).some( (i) => typeof i === "string" && i.includes("idx_qi_sku"), ); if (!hasSkuIdx) { await pb.collections.update(fresh.id, { indexes: [ ...(fresh.indexes ?? []), "CREATE INDEX `idx_qi_sku` ON `quote_items` (`sku`)", ], }); console.log("[quote-items-sku] Added idx_qi_sku."); } else { console.log("[quote-items-sku] idx_qi_sku already present — skipping."); } console.log("[quote-items-sku] Migration complete."); // Quiet any unused-var warning when this script is checked with // strict rules — `existingIndexes` is kept as documentation of the // schema state we read. void existingIndexes; } main().catch((err) => { console.error("[quote-items-sku] Migration failed:", err); process.exit(1); });