/* eslint-disable */ /** * Migration — create the empty `rfq_outbound` PocketBase collection. * * RFQ-by-email is v2's "Send to supplier and let them reply" loop. * This migration creates the table now (zero rows, zero behaviour * in v1) so the schema is ready when v2 lands — no retrofit, no * migration-with-data-backfill. * * One row per outbound RFQ email we sent on a quote_item. When the * supplier replies, the existing inbound email monitor drops a row * into `quote_replies` as today; a v2 branch in the Switch (or a * new workflow) also resolves the In-Reply-To header back to an * rfq_outbound row and links the response to the same item. * * Fields (deliberately lean — we can extend when v2 is real): * * - quote_item relation → quote_items * - supplier relation → suppliers (resolved from the * price_list row we matched against, or the * operator's override) * - email_subject text * - email_body text * - email_message_id text (Message-ID header we set when * sending — used for In-Reply-To * resolution when the supplier replies) * - sent_at date * - status text pending | replied | expired | no_reply * - raw_email_id text (optional — the raw SMTP / n8n * delivery id for audit) * * Idempotent — re-runs skip silently if the collection already * exists. * * Run with: * npx tsx scripts/migrations/2026-06-30-create-rfq-outbound.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 { /* fine */ } } 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) { 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(`[rfq-outbound] Authenticated against ${url}`); // 1. Look up the related-collection ids we need for the relations. const allCols = await pb.collections.getFullList(); const quoteItemsCol = allCols.find((c) => c.name === "quote_items"); const suppliersCol = allCols.find((c) => c.name === "suppliers"); if (!quoteItemsCol) throw new Error("quote_items collection not found."); if (!suppliersCol) throw new Error("suppliers collection not found."); // 2. Skip if already exists. const existing = allCols.find((c) => c.name === "rfq_outbound"); if (existing) { console.log(`[rfq-outbound] rfq_outbound already exists (${existing.id}) — skipping.`); console.log("[rfq-outbound] Migration complete (no-op)."); return; } // 3. Create it. Auth rules match the rest of the app — any // logged-in user can CRUD. Adjust if you want stricter. // // Note on field shape: PB's REST API expects the relation + // text/date options to be FLATTENED onto the field object // (`{ name, type, collectionId, maxSelect, … }`), NOT wrapped // inside an `options` key. Wrapping works for `collections.update` // but `collections.create` rejects the wrapped format with a // 400. `docs/pocketbase/raw_price_list.collection.json` is the // canonical reference for the flat shape. const AUTH_RULE = "@request.auth.id != ''"; const created = await pb.collections.create({ name: "rfq_outbound", type: "base", system: false, listRule: AUTH_RULE, viewRule: AUTH_RULE, createRule: AUTH_RULE, updateRule: AUTH_RULE, deleteRule: AUTH_RULE, fields: [ { name: "quote_item", type: "relation", required: true, collectionId: quoteItemsCol.id, cascadeDelete: true, maxSelect: 1, minSelect: 0, displayFields: ["description"], }, { name: "supplier", type: "relation", required: false, collectionId: suppliersCol.id, cascadeDelete: false, maxSelect: 1, minSelect: 0, displayFields: ["suppliername"], }, { name: "email_subject", type: "text", required: true, max: 500, min: 1, pattern: "", }, { name: "email_body", type: "text", required: false, max: 20000, min: 0, pattern: "", }, { name: "email_message_id", type: "text", required: false, max: 255, min: 0, pattern: "", }, { name: "sent_at", type: "date", required: false, min: "", max: "", }, // Free-text status on purpose — the lifecycle grows (v2 may // add "bounced", "auto_followed_up", "matched_to_x_supplier", // etc.) and we want zero migrations per addition. { name: "status", type: "text", required: false, max: 32, min: 0, pattern: "", }, { name: "raw_email_id", type: "text", required: false, max: 255, min: 0, pattern: "", }, ], indexes: [ "CREATE INDEX idx_rfq_outbound_quote_item ON rfq_outbound (quote_item)", "CREATE INDEX idx_rfq_outbound_message_id ON rfq_outbound (email_message_id)", "CREATE INDEX idx_rfq_outbound_status ON rfq_outbound (status)", ], }); console.log(`[rfq-outbound] Created rfq_outbound (${created.id}) — v1 leaves it empty.`); console.log("[rfq-outbound] Migration complete."); } main().catch((err) => { console.error("[rfq-outbound] Migration failed:", err); process.exit(1); });