/** * Migration — 2026-07-25-add-ai-insights-fields.ts * * Adds the four JSON fields the n8n AI agent writes to when it generates * insights for a quote/order or for an individual line item. * * ## Why JSON, not a typed schema * * The AI produces whatever it can find — the shape is fluid per record * (sometimes we have historical pricing, sometimes not; sometimes the * model surfaces a duplicate warning, sometimes a margin one). Locking * the schema down fights the model. We keep the field as a single * `json` blob and let the UI be a smart renderer (see * `src/components/ai-insights-renderer.tsx`) that handles whatever * keys come back. * * The only contract between n8n and the UI is: a top-level `summary` * string. Everything else is opportunistic. * * ## The four fields * * - quotes.ai_insights (json) — document-level card * - orders.ai_insights (json) — document-level card * - quote_items.ai_item_insights (json) — line-item popover * - order_items.ai_item_insights (json) — line-item popover * * Snapshot-once: n8n writes the field when the parent record is * created. The UI never overwrites it; the "Regenerate" button on the * card triggers a re-run of the n8n flow which then PATCHes the same * field. * * Idempotent — safe to re-run. Skips fields that already exist. * * Run with: * npx tsx scripts/migrations/2026-07-25-add-ai-insights-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 { /* fine — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); function getAdminPocketBase(): PocketBase { 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 (sourced from .env.local).", ); } return new PocketBase(url); } async function authedPb(): Promise { const pb = getAdminPocketBase(); await pb .collection("_superusers") .authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD!, ); return pb; } type AnyField = { name: string; type?: string; required?: boolean; maxSize?: number; [k: string]: unknown; }; /** * PB's `json` field shape: name + type + optional maxSize (in bytes). * No `options` wrapper — same flat shape as `select` / `file` fields, * see memory gotcha "PB select fields… create-collection API uses * `fields`, not `schema`". */ function jsonField(name: string): AnyField { return { name, type: "json", required: false, // 64KB ceiling — way more than the AI will write, but small enough // to keep PB's per-field validation snappy. Bump if the model ever // produces a legitimate >64KB insight blob. maxSize: 65_536, presentable: false, }; } const TARGETS: Array<{ collection: string; field: AnyField }> = [ { collection: "quotes", field: jsonField("ai_insights") }, { collection: "orders", field: jsonField("ai_insights") }, { collection: "quote_items", field: jsonField("ai_item_insights") }, { collection: "order_items", field: jsonField("ai_item_insights") }, ]; export async function run(): Promise { const pb = await authedPb(); // Build a per-collection name → collectionId map once, so we can // resolve by name even though the SDK wants the id. const collections = await pb.collections.getFullList(); const byName = new Map(collections.map((c) => [c.name, c])); for (const { collection, field } of TARGETS) { const col = byName.get(collection); if (!col) { console.warn( `[migration:ai-insights] skip — collection "${collection}" not found`, ); continue; } const existing = ((col.fields ?? []) as AnyField[]).map((f) => f.name); if (existing.includes(field.name)) { console.log( `[migration:ai-insights] ${collection}.${field.name} already exists — skip`, ); continue; } const next = [...((col.fields ?? []) as AnyField[]), field]; await pb.collections.update(col.id, { fields: next }); console.log( `[migration:ai-insights] ✓ added ${collection}.${field.name} (json, 64KB)`, ); } // Verify console.log("\n[migration:ai-insights] Verifying..."); const fresh = await pb.collections.getFullList(); const freshByName = new Map(fresh.map((c) => [c.name, c])); const checks: Array<[string, boolean]> = []; for (const { collection, field } of TARGETS) { const c = freshByName.get(collection); const f = ((c?.fields ?? []) as AnyField[]).find((x) => x.name === field.name); checks.push([ `${collection}.${field.name} is json`, f?.type === "json", ]); } let allOk = true; for (const [label, ok] of checks) { console.log(` ${ok ? "✓" : "✗"} ${label}`); if (!ok) allOk = false; } if (!allOk) { throw new Error("[migration:ai-insights] verification failed"); } console.log("[migration:ai-insights] Migration complete."); } if (require.main === module) { run().catch((err) => { console.error("[migration:ai-insights] fatal:", err); process.exit(1); }); }