/** * Migration: 2026-07-06 — quote_attachments: add quote_item relation + kind field * * What it does: * 1. Adds `quote_item` relation to `quote_attachments` (nullable, single, * collectionId = quote_items). Lets a per-line attachment live alongside * per-quote attachments on the same collection instead of needing a * separate `quote_item_attachments` table. * * 2. Adds `kind` select field (pdf / image / doc / other) to * `quote_attachments`. The TypeScript interface in `src/lib/types.ts` * already declares `kind` (and `QuoteAttachmentKind`) — the v5 attachment * workflow writes it on upload. Live PB never got the migration that adds * the field, so existing rows have `kind = null`. The migration is * idempotent: it skips when the field already exists. * * Why both at once: * The order process page surfaces matched quote + per-line attachments so * the buyer can verify they're ordering the right SKU from the right * supplier. Without `quote_item` we can't link an attachment to a specific * line; without `kind` we can't pick the right thumbnail (PDF vs image vs * doc icon). Both are needed for the inline preview UI to be usable. * * Idempotency: * Both field adds are skipped if a field with that name already exists. * Tested collection ids are discovered by name from PB on every run, so * this migration works across environments. * * Run: * set -a && source .env.local && set +a && \ * npx tsx scripts/migrations/2026-07-06-quote-attachments-quote-item-and-kind.ts */ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; type PbField = { id?: string; name: string; type: string; collectionId?: string; values?: string[]; required?: boolean; maxSelect?: number; minSelect?: number; cascadeDelete?: boolean; [k: string]: unknown; }; async function main() { const pb = await getAdminPocketBase(); // Resolve by name — works across PB instances even if the id shifts. const coll = await pb.collections.getFirstListItem(`name="quote_attachments"`); const quoteItemsColl = await pb.collections.getFirstListItem(`name="quote_items"`); const fields = ((coll as unknown as { fields?: PbField[] }).fields ?? []) as PbField[]; const existingNames = new Set(fields.map((f) => f.name)); const planned: string[] = []; if (existingNames.has("quote_item")) { planned.push(`field "quote_item" already present — skip`); } else { fields.push({ hidden: false, name: "quote_item", presentable: false, required: false, system: false, type: "relation", collectionId: quoteItemsColl.id, cascadeDelete: false, minSelect: 0, maxSelect: 1, }); planned.push(`add "quote_item" relation field (→ quote_items, nullable)`); } if (existingNames.has("kind")) { planned.push(`field "kind" already present — skip`); } else { fields.push({ hidden: false, name: "kind", presentable: false, required: false, system: false, type: "select", maxSelect: 1, values: ["pdf", "image", "doc", "other"], }); planned.push(`add "kind" select field (pdf | image | doc | other)`); } const willChange = planned.some((p) => !p.includes("skip")); console.log("Planned schema changes:"); for (const p of planned) console.log(` • ${p}`); if (willChange) { await pb.collections.update(coll.id, { fields }); console.log("\n[done] schema updated."); } else { console.log("\n[done] schema already up to date — no changes written."); } // Read back to confirm what landed. const after = (await pb.collections.getOne(coll.id)) as { fields?: PbField[] }; const afterNames = (after.fields ?? []).map((f) => f.name).join(", "); console.log(`\nquote_attachments fields now: ${afterNames}`); } main().catch((err) => { console.error("[fatal]", err); process.exit(1); });