/* eslint-disable */ /** * Migration: add `direction` field to `quote_attachments` collection. * * Three values: * - "inbound" — file came in with the original RFQ email * - "outbound" — file the operator uploaded to send with the quote * - "generated" — PDF auto-generated by the /api/v1/quotes/[id]/pdf * endpoint and saved back to this collection * * Backfill rules for existing rows: * - email_message_id is set AND non-empty → "inbound" * - email_message_id is empty AND kind == "pdf" → "generated" * - everything else (operator pre-staged files, etc.) → "outbound" * * After this migration the UI on the quote detail page can filter by * direction=outbound to show the "files to send with this quote" card, * separate from the inbound attachments visible in the email card. * * Idempotent: running twice is safe — it skips the field-create if * `direction` is already present, and updates each row's `direction` * only if it differs from what we'd backfill (so we don't churn * rows that were already set explicitly by the UI). * * Required env (in .env or shell): * NEXT_PUBLIC_POCKETBASE_URL * PB_ADMIN_EMAIL * PB_ADMIN_PASSWORD * * Usage: npx tsx scripts/migrations/2026-06-23-add-quote-attachments-direction.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // Load .env / .env.local if present (tsx doesn't auto-load them) 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 { // file not present — fine, rely on shell env } } loadEnvFile(".env"); loadEnvFile(".env.local"); 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) { console.error("Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD"); process.exit(1); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); // Look up the collection by name so this migration works against any // environment (the local snapshot doesn't include quote_attachments // and the live id may differ between PB instances). const coll = await pb.collections.getFirstListItem(`name="quote_attachments"`); const fields = ((coll as { fields?: Array> }).fields ?? []) as Array< Record >; type FieldDef = Record; const DIRECTION_FIELD_ID = "sel_quote_attachments_direction"; const planned: string[] = []; if (fields.some((f) => f.name === "direction")) { planned.push(`field "direction" already present — skip`); } else { const newField: FieldDef = { help: "inbound: came in with the original RFQ email. " + "outbound: operator uploaded to send with the quote. " + "generated: PDF rendered by /api/v1/quotes/[id]/pdf.", hidden: false, id: DIRECTION_FIELD_ID, maxSelect: 1, name: "direction", presentable: false, required: false, system: false, type: "select", values: ["inbound", "outbound", "generated"], }; fields.push(newField); planned.push(`add "direction" select field (inbound | outbound | generated)`); } const willChangeSchema = planned.some((p) => !p.includes("skip")); if (willChangeSchema) { console.log("Planned schema changes:"); for (const p of planned) console.log(` • ${p}`); await pb.collections.update(coll.id, { fields }); } else { console.log("Schema already up to date — skipping field create."); } // Backfill `direction` on existing rows. We only touch rows that // don't have it set yet, OR rows whose value disagrees with what the // backfill rule would compute — leaves user-edited values alone. console.log("\nBackfilling existing rows…"); const all = await pb.collection("quote_attachments").getFullList<{ id: string; direction?: string | null; email_message_id?: string; kind?: string; }>({ perPage: 500 }); let touched = 0; let skipped = 0; for (const row of all) { const want = computeDirection(row); if (row.direction === want) { skipped++; continue; } await pb.collection("quote_attachments").update(row.id, { direction: want }); touched++; } console.log(` • rows scanned: ${all.length}`); console.log(` • rows updated: ${touched}`); console.log(` • rows skipped (already correct): ${skipped}`); const after = await pb.collections.getOne(coll.id); const afterFields = ((after as { fields?: Array> }).fields ?? []) as Array< Record >; const directionField = afterFields.find((f) => f.name === "direction"); const values = (directionField?.values as string[] | undefined) ?? []; console.log("\nMigration complete."); console.log(" direction field present: ", afterFields.some((f) => f.name === "direction")); console.log(' includes "inbound": ', values.includes("inbound")); console.log(' includes "outbound": ', values.includes("outbound")); console.log(' includes "generated": ', values.includes("generated")); if ( !afterFields.some((f) => f.name === "direction") || !values.includes("inbound") || !values.includes("outbound") || !values.includes("generated") ) { console.error("\n!!! Migration verification failed. Please inspect manually."); process.exit(1); } console.log("\nAll good. The Outbound Attachments card on /quotes/[id] will now work."); } /** * Backfill rule for one row. Exported via a small wrapper so the * migration script stays flat; matches the spec in the file header. */ function computeDirection(row: { email_message_id?: string; kind?: string; }): "inbound" | "outbound" | "generated" { const mid = (row.email_message_id || "").trim(); if (mid) return "inbound"; if ((row.kind || "").toLowerCase() === "pdf") return "generated"; return "outbound"; } main().catch((err) => { console.error("Migration failed:", err); process.exit(1); });