/** * Backfill: heal broken `ai_item_insights.item.id` fields. * * Context * ──────── * The `ItemIntelPopover` apply button uses * `intel.item.id || itemId` * to decide which PB record to PATCH. The user-side `itemId` prop is * the authoritative target, but the stored blobl was historically * written without that field (or written for the wrong collection). * * Live data: * - 246 items have `intel.item.id = ""` (empty string) * - 32 items have `intel.item` missing entirely * - Of those, many also have `intel.item.collection = "quote_items"` * on an order_item record (the blob was carried over from a quote * without the field being re-stamped) * * The `??` operator in the popover only catches null/undefined, so an * empty string ids bypasses the fallback and the apply fails. The * popover now uses `||` which catches empty strings too — this script * heals the existing data so the blob is correct end-to-end. * * What it does * ──────────── * For every `order_items` and `quote_items` row that has a non-null * `ai_item_insights` blob: * - If `intel.item` is missing OR `intel.item.id` is empty OR * `intel.item.collection` doesn't match the source collection: * rebuild `intel.item` from the item's own id/description/sku/etc. * - Writes the corrected blob back to the row. * - Leaves the rest of the blob (stock_list, supplier_history, * price_list, recent_quote, recent_order, summary, generated_at, * verified) untouched. * * Idempotent: re-running produces no further changes once everything * is healed. Add `--dry-run` to preview without writing. * * Run with: * npx tsx --env-file=.env.local scripts/2026-08-13-heal-item-intel.ts * * Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, * PB_ADMIN_PASSWORD. */ import "./_env"; // side-effect import: loads .env.local before anything else import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; const DRY_RUN = process.argv.includes("--dry-run"); async function healCollection( collection: "order_items" | "quote_items", pb: Awaited>, ): Promise<{ scanned: number; repaired: number; skipped: number }> { let scanned = 0; let repaired = 0; let skipped = 0; // Pull every row with a non-null blob. We deliberately keep all // fields on the blobs we read so we can write them back unchanged. const rows = await pb.collection(collection).getFullList<{ id: string; description: string; sku: string; quantity: number; ai_item_insights: Record | null; }>({ filter: `ai_item_insights != "" && ai_item_insights != null`, fields: "id,description,sku,quantity,ai_item_insights", batch: 500, }); for (const row of rows) { scanned++; const blob = row.ai_item_insights; if (!blob || typeof blob !== "object") { skipped++; continue; } const item = (blob as { item?: unknown }).item; const itemObj = item && typeof item === "object" ? (item as Record) : null; const itemId = itemObj?.id; const itemCollection = itemObj?.collection; const needsFix = !itemObj || typeof itemId !== "string" || itemId === "" || itemCollection !== collection; if (!needsFix) { continue; } const fixedItem = { id: row.id, collection, description: (itemObj?.description as string) || row.description || "", sku: (itemObj?.sku as string) || row.sku || "", quantity: (itemObj?.quantity as number) || row.quantity || 0, }; if (DRY_RUN) { console.log(` [DRY] ${collection} ${row.id}: would fix item.id "" → "${row.id}"`); repaired++; continue; } await pb.collection(collection).update(row.id, { ai_item_insights: { ...blob, item: fixedItem }, }); repaired++; if (repaired % 25 === 0) { console.log(` ... ${repaired} repaired so far`); } } return { scanned, repaired, skipped }; } async function main() { const startedAt = Date.now(); console.log(`=== Heal broken item-intel.item.id fields ===\n`); if (DRY_RUN) console.log(" (DRY RUN — no writes)\n"); const pb = await getAdminPocketBase(); const orderResult = await healCollection("order_items", pb); console.log(`order_items: scanned=${orderResult.scanned} repaired=${orderResult.repaired} skipped=${orderResult.skipped}`); const quoteResult = await healCollection("quote_items", pb); console.log(`quote_items: scanned=${quoteResult.scanned} repaired=${quoteResult.repaired} skipped=${quoteResult.skipped}`); const totalScanned = orderResult.scanned + quoteResult.scanned; const totalRepaired = orderResult.repaired + quoteResult.repaired; console.log(`\n=== Done ===`); console.log(` Total scanned: ${totalScanned}`); console.log(` Total repaired: ${totalRepaired}`); console.log(` Total skipped: ${totalScanned - totalRepaired}`); console.log(` Duration: ${((Date.now() - startedAt) / 1000).toFixed(1)}s`); if (DRY_RUN) console.log(` (Run without --dry-run to write)`); } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });