/* eslint-disable */ /** * Migration — Sprint Quote Items (V1 foundation) * * Adds the data model the new /sprint page and supplier-RFQ email * flow sit on: * * 1. New `categories` collection (admin-managed lookup of the tabs * on /sprint). Seeded with the user's initial buckets plus * UNKNOWN. Three match signals — `match_brands` (highest * priority), `match_supplier_tags` (medium), `match_keywords` * (fallback against description/SKU). UNKNOWN has no matchers; * it's the catch-all bucket. * * 2. New fields on `quote_items`: * - category relation → categories (for tab bucketing) * - claim_status select: unclaimed | claimed | priced * | awaiting_supplier | send_failed * - claimed_by text (user email) * - claimed_at date (when claim was made) * - claim_expires_at date (30 min soft TTL — auto-release) * - outbound_message_id text (RFC 5322 Message-ID of the RFQ * email we sent to the supplier; * matched on In-Reply-To for * inbound reply routing) * - outbound_sent_at date (when we sent the RFQ) * - supplier_replied_at date (set by Supplier RFQ Monitor on inbound) * - supplier_reply_count number (incremented on each inbound reply) * * 3. Indexes: * - idx_qi_claim_status — sprint list filter * - idx_qi_outbound_message_id — supplier reply lookup (O(log n)) * - idx_qi_category — tab filter * * 4. Backfills (idempotent): * - quote_items.category ← price_list.brand via match_brands * - quote_items.claim_status ← is_processed (priced vs unclaimed) * * All steps are idempotent — re-running after a partial success is safe. * * Run with: * npx tsx scripts/migrations/2026-07-02-add-sprint-categories-and-claims.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // ─── env loading (matches sibling migrations) ─────────────────────────── 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"); // ─── seed data for categories ──────────────────────────────────────────── // Keyword/tag lists are lowercased before match. Tune these freely — // they're admin-curated, not AI-generated. const SEED_CATEGORIES: Array> = [ { name: "JEI", slug: "jei", match_brands: "jei", color: "#4C96D7", display_order: 10, is_active: true, }, { name: "TOOLS", slug: "tools", match_keywords: "tool,wrench,screwdriver,hammer,pliers,saw,drill,grinder,spanner,socket set", match_supplier_tags: "hand tools,stronghand tools,tools,generators / stronghand tools,dumpers and drill rigs - tools", color: "#F2AF58", display_order: 20, is_active: true, }, { name: "FASTENERS", slug: "fasteners", match_keywords: "bolt,nut,screw,washer,fastener,stud,anchor,thread,rod,pin,clamp,hoseclamp", match_supplier_tags: "bolts,fastners,fasteners,hardware items,engineering hoseclamps", color: "#7c3aed", display_order: 30, is_active: true, }, { name: "OIL", slug: "oil", match_keywords: "oil,lubricant,grease,fluid,hydrocarbon,diesel,petrol,cutting oil", match_supplier_tags: "f61 grease,grease pumps,oil,automotive spares,chemical,chemicals", color: "#0f766e", display_order: 40, is_active: true, }, { name: "HUSQVARNA - GARDEN & FORESTRY", slug: "husqvarna-garden-forestry", match_keywords: "husqvarna,chainsaw,forestry,garden,trimmer,brushcutter,leaf blower,hedge trimmer,lawnmower", match_supplier_tags: "chainsaws,garden,forestry,camping supplies", color: "#E94E49", display_order: 50, is_active: true, }, { name: "UNKNOWN", slug: "unknown", color: "#64748b", display_order: 9999, is_active: true, }, ]; // ─── main ──────────────────────────────────────────────────────────────── 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(`[sprint] Authenticated against ${url}`); // ─── 1. categories collection ────────────────────────────────────── const cols = await pb.collections.getFullList(); let catsCol = cols.find((c) => c.name === "categories"); if (catsCol) { console.log(`[sprint] categories collection already exists (${catsCol.id})`); } else { const def = JSON.parse( readFileSync( resolve(process.cwd(), "docs/pocketbase/categories.collection.json"), "utf8", ), ); catsCol = await pb.collections.create(def); console.log(`[sprint] Created categories collection (${catsCol.id})`); } const catsId = catsCol.id; // ─── 2. seed categories (idempotent via slug lookup) ─────────────── console.log(`[sprint] Seeding ${SEED_CATEGORIES.length} categories…`); for (const seed of SEED_CATEGORIES) { try { const existing = await pb .collection("categories") .getFirstListItem(`slug = "${seed.slug}"`); console.log(`[sprint] ${seed.slug} exists (${existing.id}) — skip`); } catch (e: unknown) { const status = (e as { status?: number }).status; if (status === 404) { const created = await pb.collection("categories").create(seed); console.log(`[sprint] seeded ${seed.slug} (${created.id})`); } else { throw e; } } } // ─── 3. quote_items: add 9 new fields (idempotent) ──────────────── const qi = await pb.collections.getOne("quote_items"); const existing = new Set(qi.fields.map((f: { name: string }) => f.name)); const newFields: Array> = []; if (!existing.has("category")) { newFields.push({ name: "category", type: "relation", required: false, collectionId: catsId, maxSelect: 1, minSelect: 0, cascadeDelete: false, }); } if (!existing.has("claim_status")) { newFields.push({ name: "claim_status", type: "select", required: false, maxSelect: 1, values: [ "unclaimed", "claimed", "priced", "awaiting_supplier", "send_failed", ], }); } if (!existing.has("claimed_by")) { newFields.push({ name: "claimed_by", type: "text", required: false, options: { max: 100, pattern: "" }, }); } if (!existing.has("claimed_at")) { newFields.push({ name: "claimed_at", type: "date", required: false, options: { min: "", max: "" }, }); } if (!existing.has("claim_expires_at")) { newFields.push({ name: "claim_expires_at", type: "date", required: false, options: { min: "", max: "" }, }); } if (!existing.has("outbound_message_id")) { newFields.push({ name: "outbound_message_id", type: "text", required: false, options: { max: 255, pattern: "" }, }); } if (!existing.has("outbound_sent_at")) { newFields.push({ name: "outbound_sent_at", type: "date", required: false, options: { min: "", max: "" }, }); } if (!existing.has("supplier_replied_at")) { newFields.push({ name: "supplier_replied_at", type: "date", required: false, options: { min: "", max: "" }, }); } if (!existing.has("supplier_reply_count")) { newFields.push({ name: "supplier_reply_count", type: "number", required: false, options: { min: 0, max: null, noDecimal: true }, }); } if (newFields.length > 0) { await pb.collections.update(qi.id, { fields: [...qi.fields, ...newFields], }); console.log( `[sprint] Added quote_items fields: ${newFields .map((f) => (f as { name: string }).name) .join(", ")}`, ); } else { console.log("[sprint] All claim fields already present — skip"); } // ─── 4. quote_items: add 3 indexes (idempotent) ────────────────── const fresh = await pb.collections.getOne("quote_items"); const idxs = (fresh.indexes ?? []) as string[]; const newIndexes: string[] = []; if (!idxs.some((i) => i.includes("idx_qi_claim_status"))) { newIndexes.push( "CREATE INDEX `idx_qi_claim_status` ON `quote_items` (`claim_status`)", ); } if (!idxs.some((i) => i.includes("idx_qi_outbound_message_id"))) { newIndexes.push( "CREATE INDEX `idx_qi_outbound_message_id` ON `quote_items` (`outbound_message_id`)", ); } if (!idxs.some((i) => i.includes("idx_qi_category"))) { newIndexes.push( "CREATE INDEX `idx_qi_category` ON `quote_items` (`category`)", ); } if (newIndexes.length > 0) { await pb.collections.update(fresh.id, { indexes: [...idxs, ...newIndexes], }); console.log( `[sprint] Added indexes: ${newIndexes .map((i) => i.match(/`([^`]+)`/)?.[1]) .filter(Boolean) .join(", ")}`, ); } else { console.log("[sprint] All sprint indexes already present — skip"); } // ─── 5. backfill quote_items.category from price_list.brand ─────── console.log( "[sprint] Backfilling quote_items.category from price_list.brand…", ); const catsRows = await pb.collection("categories").getFullList({ batch: 50, }); const brandToCat = new Map(); for (const c of catsRows) { const brands = String(c.match_brands || "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); for (const b of brands) brandToCat.set(b, c.id); } console.log( `[sprint] brand→category map: ${[...brandToCat.entries()] .map(([b, id]) => `${b}→${id.slice(0, 6)}…`) .join(", ") || "(empty)"}`, ); let page = 1; let scanned = 0; let updated = 0; let skipped = 0; while (true) { const batch = await pb.collection("quote_items").getList(page, 200, { filter: 'matched_price_list_id != "" && category = ""', fields: "id,matched_price_list_id", }); if (batch.items.length === 0) break; for (const item of batch.items) { scanned++; try { const pl = await pb .collection("price_list") .getOne(item.matched_price_list_id, { fields: "brand" }); const catId = brandToCat.get( String(pl.brand || "").toLowerCase().trim(), ); if (catId) { await pb.collection("quote_items").update(item.id, { category: catId, }); updated++; } else { skipped++; } } catch (_e) { // price_list row deleted/missing — skip silently skipped++; } } if (batch.page >= batch.totalPages) break; page++; } console.log( `[sprint] scanned ${scanned}, backfilled ${updated}, skipped ${skipped}`, ); // ─── 6. backfill quote_items.claim_status from is_processed ─────── console.log( "[sprint] Backfilling quote_items.claim_status from is_processed…", ); let bp = 1; let bs = 0; let bu = 0; while (true) { const batch = await pb.collection("quote_items").getList(bp, 200, { filter: 'claim_status = "" || claim_status = null', fields: "id,is_processed", }); if (batch.items.length === 0) break; for (const item of batch.items) { bs++; const status = item.is_processed ? "priced" : "unclaimed"; await pb.collection("quote_items").update(item.id, { claim_status: status, }); bu++; } if (batch.page >= batch.totalPages) break; bp++; } console.log(`[sprint] scanned ${bs}, set claim_status on ${bu}`); console.log("[sprint] Migration complete."); } main().catch((err) => { console.error("[sprint] Migration failed:", err); process.exit(1); });