/** * One-shot migration: add `user_edited` to the live `quote_items` * collection (idempotent) and backfill existing rows. * * Run with: * * npx tsx scripts/migrations/add-quote-items-user-edited.ts * * Backfill rule: * - Items with a non-empty description that were UPDATED after * creation (i.e. a human saved them via the process page) are * set to `user_edited = true`. * - Items that were created and never updated (auto-extracted from * the email pipeline) are left as `user_edited = false`. The * rewriter gate will refuse them until the user edits + saves * them in the process page — which is the intended behaviour. * * The script is safe to run multiple times. */ import PocketBase from "pocketbase"; const PB_URL = process.env.PB_URL || "http://127.0.0.1:8090"; const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; async function main() { if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars."); } const pb = new PocketBase(PB_URL); await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); console.log(`[user_edited] Authenticated against ${PB_URL}`); // 1. Ensure the field exists on the collection. const collection = await pb.collections.getOne("quote_items"); const hasField = collection.fields.some( (f: { name: string }) => f.name === "user_edited" ); if (!hasField) { console.log("[user_edited] Field missing — adding it to quote_items."); await pb.collections.update(collection.id, { fields: [ ...collection.fields, { name: "user_edited", type: "bool", required: false, presentable: false, }, ], }); console.log("[user_edited] Field added."); } else { console.log("[user_edited] Field already exists — skipping schema update."); } // 2. Backfill. Pull in pages of 200 so we don't OOM on large tables. let page = 1; let scanned = 0; let updated = 0; let skippedAlreadyTrue = 0; let skippedMachine = 0; while (true) { const batch = await pb.collection("quote_items").getList(page, 200, { fields: "id,description,created,updated,user_edited", }); if (batch.items.length === 0) break; for (const item of batch.items) { scanned++; if (item.user_edited === true) { skippedAlreadyTrue++; continue; } // Heuristic: a non-empty description AND a meaningful gap // between created and updated timestamps (PB stores ISO; Date // diff > 1000ms is enough) means a human saved this row at // least once. Empty description or created==updated means it's // still in its auto-extracted state. const hasDesc = typeof item.description === "string" && item.description.trim().length > 0; const humanSaved = hasDesc && new Date(item.updated).getTime() - new Date(item.created).getTime() > 1000; if (!humanSaved) { skippedMachine++; continue; } await pb.collection("quote_items").update(item.id, { user_edited: true }); updated++; } if (batch.page >= batch.totalPages) break; page++; } console.log(`[user_edited] Scanned ${scanned} items.`); console.log(`[user_edited] Flipped to true: ${updated}`); console.log(`[user_edited] Already true: ${skippedAlreadyTrue}`); console.log(`[user_edited] Left as false (machine / no description): ${skippedMachine}`); console.log("[user_edited] Done."); } main().catch((err) => { console.error("[user_edited] Migration failed:", err); process.exit(1); });