/* eslint-disable */ /** * Migration: add `ai_status` + `ai_error` + `ai_processed_at` to quotes. * * Why this exists * ─────────────── * The quote-intake pipeline is being split in two: * * 1. Ingestion (fast) — accepts the raw email payload, creates a quote * row with `ai_status = "pending"`, uploads attachments. Returns * immediately so n8n's webhook doesn't time out on a slow AI call. * * 2. AI processing (async) — a separate cron endpoint scans quotes * with `ai_status = "pending"` or `"failed"`, runs Claude against * the body + attachments, writes `quote_items`, flips status. * * Three new fields are needed on `quotes`: * * ai_status select single-select lifecycle flag * pending → processing → complete * ↓ * failed (retry-eligible) * not_applicable for manually-created * quotes that never went through email. * * ai_error text Last AI error message — surfaced in the * operator UI when ai_status='failed'. * * ai_processed_at date Timestamp of the most recent AI run, for * the "last attempted" badge in the UI. * * Why this reads pb-exports/pb_schema-2.json * ─────────────────────────────────────────── * PB 0.26's `collections.getOne()` strips the `options.values` array * from select fields created under PB 0.22 or earlier — they come * back as `options: undefined`. Worse, when re-PUTting the fields * array, PB validates EVERY field (not just the new ones), so any * existing select field with a missing `values` array kills the whole * update with `values: validation_required`. * * The schema export is the only place the full definition survives. * We source the field list from the export, append our additions, * and PUT the whole array back. Select fields in PB 0.26 on this * instance still accept the legacy PB 0.22 shape (`values` at the * top level of the field, not nested under `options`), so we pass * them through unchanged. * * Schema * ────── * quotes.ai_status select (new, nullable, single-select) * quotes.ai_error text (new, max 4900, nullable) * quotes.ai_processed_at date (new, nullable) * * Idempotent — safe to re-run. Subsequent runs print "already present, * skipping." * * Run with: * npx tsx scripts/migrations/2026-08-08-add-quotes-ai-status.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"); const AI_STATUS_VALUES = [ "pending", "processing", "complete", "failed", "not_applicable", ] as const; const SCHEMA_EXPORT_PATH = resolve( process.cwd(), "pb-exports", "pb_schema-2.json", ); 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 and PB_ADMIN_PASSWORD.", ); } // ─── 1. Load the full schema from the export so existing select // field values aren't lost when we PUT the whole array back. ── let schemaExport: Array>; try { schemaExport = JSON.parse(readFileSync(SCHEMA_EXPORT_PATH, "utf8")) as Array< Record >; } catch { throw new Error( `Could not read ${SCHEMA_EXPORT_PATH}. The export is the source of truth for select field values.`, ); } const quotesExport = schemaExport.find((c) => c.name === "quotes"); if (!quotesExport) { throw new Error('Schema export does not contain a "quotes" collection.'); } const existingFields = (quotesExport.fields ?? []) as Array< Record >; const existingNames = new Set(existingFields.map((f) => f.name as string)); const pb = new PocketBase(url); await pb.admins.authWithPassword(email, password); const quotesLive = await pb.collections.getOne("quotes"); const existingIndexes = (quotesLive.indexes ?? []) as string[]; // ─── 2. Add the three fields ──────────────────────────────────── const additions: Array> = []; if (!existingNames.has("ai_status")) { additions.push({ name: "ai_status", type: "select", required: false, presentable: false, system: false, hidden: false, help: "AI processing state for email-intake quotes. pending|processing|complete|failed|not_applicable", // PB 0.26 on this instance still accepts the legacy PB 0.22 // shape (`values` at the top level) for select fields. Keeping // it consistent with the existing `status` and // `company_letterhead` fields. values: [...AI_STATUS_VALUES], maxSelect: 1, }); } else { console.log("quotes.ai_status already present, skipping."); } if (!existingNames.has("ai_error")) { additions.push({ name: "ai_error", type: "text", required: false, presentable: false, system: false, hidden: false, help: "Last AI error message — surfaced in the operator UI when ai_status='failed'.", options: { max: 4900, min: 0, pattern: "" }, }); } else { console.log("quotes.ai_error already present, skipping."); } if (!existingNames.has("ai_processed_at")) { additions.push({ name: "ai_processed_at", type: "date", required: false, presentable: false, system: false, hidden: false, help: "Timestamp of the most recent AI run.", options: { min: "", max: "" }, }); } else { console.log("quotes.ai_processed_at already present, skipping."); } if (additions.length === 0) { console.log("All three AI fields already present on quotes — nothing to add."); } else { // Pass the existing fields through unchanged (they're already in // the shape PB accepts on this instance). Append our additions. await pb.collections.update(quotesLive.id, { fields: [...existingFields, ...additions], }); console.log( `Added quotes fields: ${additions.map((f) => f.name).join(", ")}`, ); } // ─── 3. Index on `ai_status` ───────────────────────────────────── // The AI processor's main query is: // ai_status = "pending" OR ai_status = "failed" // Without an index this is a full table scan on every cron tick. // ~8.5k quotes today; still cheap, but the index is cheap insurance // against growth and matches the `idx_quotes_status` pattern from // the 2026-08-04 quotes-status-index migration. const INDEX_NAME = "idx_quotes_ai_status"; const INDEX_SQL = `CREATE INDEX \`${INDEX_NAME}\` ON \`quotes\` (\`ai_status\`)`; if (!existingIndexes.includes(INDEX_SQL)) { await pb.collections.update(quotesLive.id, { indexes: [...existingIndexes, INDEX_SQL], }); console.log("Added quotes.ai_status index (idx_quotes_ai_status)."); } else { console.log("quotes.ai_status index already present, skipping."); } console.log("\nMigration complete."); console.log( "Next step: implement src/lib/server/quotes-from-email-service.ts (ingestion, sets ai_status='pending'),", ); console.log( "then src/lib/server/quotes-ai-processor.ts (background processor, flips ai_status='complete').", ); } main().catch((error) => { console.error("[add-quotes-ai-status] failed:", error); process.exit(1); });