/** * Migration — 2026-07-24-partial-approval.ts * * Adds the schema needed by the partial-approval flow * (sprint-priced items can be approved + sent in batches; * unpriced items get cloned to a continuation quote). * * ## What this script does * * 1. Reconciles `quotes.status` to add the two new enum values: * `partial_approval_pending` and `partial_approved`. * The existing values are kept; we only APPEND the new ones. * This is the path the operator's "Submit for Approval" / * "Approve & Send" actions take when only some items on the * quote are priced. * * 2. Adds `parent_quote` (relation → `quotes`, single, optional, * cascadeDelete: false) to the `quotes` collection. When a * continuation quote is created from a partial-approved * parent, the continuation's `parent_quote` points back to * the original. Lets the UI render "Continuation of Q-..." * banners and a "View parent" back-link. * * 3. Adds `approved_in_batch` (bool, default false) to * `quote_items`. Set to true by /api/v1/quotes/[id]/partial-approve * on the priced items the rep just approved. Gates the * re-submission flow — a second partial approval only * considers items where `approved_in_batch` is still false. * * 4. Adds `moved_to_quote` (relation → `quotes`, single, * optional, cascadeDelete: false) to `quote_items`. Set by * /api/v1/quotes/[id]/split to the continuation quote an * item was cloned to. The PDF route and approval UI filter * out items where `moved_to_quote != null` so a parent's * PDF/approval state never re-includes items that now * belong to a separate quote. * * ## Why a migration script (and not "user does it in admin") * * The user's AGENTS.md rule about "PocketBase select fields are * not self-service" is about RUNTIME — if the app code PATCHes a * status to a value that doesn't exist, the API 500s. The * migration script is exactly the way you add the enum value * programmatically. After this runs, the admin UI shows the new * values too (and the user can re-order them if they want). * * ## Idempotency * * Safe to re-run. Each step reads the current collection state, * computes a diff, and only PATCHes if something is missing. * * ## Run * * npx tsx scripts/migrations/2026-07-24-partial-approval.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 — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); function getAdminPocketBase(): PocketBase { 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 (sourced from .env.local).", ); } const pb = new PocketBase(url); return pb; } async function authedPb(): Promise { const pb = getAdminPocketBase(); await pb .collection("_superusers") .authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD!, ); return pb; } const QUOTES = "quotes"; const QUOTE_ITEMS = "quote_items"; /** * Target `quotes.status` enum. Mirrors the union in * `src/lib/types.ts` → `QuoteStatus`. The migration script is * the source of truth for what the live PB actually accepts; the * TS type is checked against this in code review. */ const TARGET_QUOTE_STATUS_VALUES = [ "new", "claimed", "processed", "emailed", "approval_pending", "approved", "rejected", "problem", "complete", "cancelled", "follow_up", "email_failed", "unmatched_bounce", "template", "sending", "mail_failed", "delivery_note", // Added 2026-07-24 — partial-approval flow. "partial_approval_pending", "partial_approved", ]; type PbField = { name: string; type: string; required?: boolean; values?: string[]; collectionId?: string; maxSelect?: number; cascadeDelete?: boolean; options?: Record; [k: string]: unknown; }; export async function run(): Promise { const pb = await authedPb(); const quotesCol = await pb.collections.getOne(QUOTES); const quoteItemsCol = await pb.collections.getOne(QUOTE_ITEMS); const quotesFields = (quotesCol.fields ?? []) as PbField[]; const itemsFields = (quoteItemsCol.fields ?? []) as PbField[]; // ── 1. Reconcile quotes.status enum ────────────────────────── // APPEND the two new values if missing. We never delete or // reorder existing values — backwards compat with already-stored // quote rows. PB stores select values as a TOP-LEVEL `values` // array (not nested under `options`) — see the gotcha note in // the project AGENTS.md and the 2026-07-19 delivery-notes // migration that ran into the same trap. let nextQuotesFields = quotesFields; let statusNeedsUpdate = false; const statusField = nextQuotesFields.find( (f) => f.name === "status" && f.type === "select", ); const existingValues = new Set(statusField?.values ?? []); const missingValues = TARGET_QUOTE_STATUS_VALUES.filter( (v) => !existingValues.has(v), ); if (missingValues.length > 0) { const merged = [ ...(statusField?.values ?? []), ...missingValues, ]; nextQuotesFields = nextQuotesFields.map((f) => f.name === "status" && f.type === "select" ? { ...f, values: merged } : f, ); statusNeedsUpdate = true; } // ── 2. Add parent_quote relation to quotes ─────────────────── const hasParentQuote = nextQuotesFields.some( (f) => f.name === "parent_quote" && f.type === "relation", ); if (!hasParentQuote) { nextQuotesFields = [ ...nextQuotesFields, { name: "parent_quote", type: "relation", required: false, collectionId: quotesCol.id, // self-reference — set after getOne above cascadeDelete: false, maxSelect: 1, }, ]; } // ── 3. Add approved_in_batch bool to quote_items ───────────── const hasApprovedInBatch = itemsFields.some( (f) => f.name === "approved_in_batch" && f.type === "bool", ); // ── 4. Add moved_to_quote relation to quote_items ──────────── const hasMovedToQuote = itemsFields.some( (f) => f.name === "moved_to_quote" && f.type === "relation", ); const itemsNeedsUpdate = !hasApprovedInBatch || !hasMovedToQuote; let nextItemsFields = itemsFields; if (itemsNeedsUpdate) { if (!hasApprovedInBatch) { nextItemsFields = [ ...nextItemsFields, { name: "approved_in_batch", type: "bool", required: false }, ]; } if (!hasMovedToQuote) { nextItemsFields = [ ...nextItemsFields, { name: "moved_to_quote", type: "relation", required: false, collectionId: quotesCol.id, // target = quotes cascadeDelete: false, maxSelect: 1, }, ]; } } // ── Apply the updates ──────────────────────────────────────── if (statusNeedsUpdate || !hasParentQuote) { await pb.collections.update(QUOTES, { fields: nextQuotesFields }); const parts: string[] = []; if (statusNeedsUpdate) { parts.push(`status enum +${missingValues.length} value(s)`); } if (!hasParentQuote) { parts.push("added parent_quote relation"); } console.log( `[migration:partial-approval] ✓ patched ${QUOTES}: ${parts.join(", ")}`, ); } else { console.log( `[migration:partial-approval] ${QUOTES} already up to date — skip`, ); } if (itemsNeedsUpdate) { await pb.collections.update(QUOTE_ITEMS, { fields: nextItemsFields }); const parts: string[] = []; if (!hasApprovedInBatch) parts.push("added approved_in_batch bool"); if (!hasMovedToQuote) parts.push("added moved_to_quote relation"); console.log( `[migration:partial-approval] ✓ patched ${QUOTE_ITEMS}: ${parts.join(", ")}`, ); } else { console.log( `[migration:partial-approval] ${QUOTE_ITEMS} already up to date — skip`, ); } // ── Verify ─────────────────────────────────────────────────── const vQuotes = await pb.collections.getOne(QUOTES); const vItems = await pb.collections.getOne(QUOTE_ITEMS); const vQuotesFields = (vQuotes.fields ?? []) as PbField[]; const vItemsFields = (vItems.fields ?? []) as PbField[]; const checks: Array<[string, boolean]> = [ [ "quotes.status contains partial_approval_pending", (vQuotesFields.find((f) => f.name === "status")?.values ?? []).includes( "partial_approval_pending", ), ], [ "quotes.status contains partial_approved", (vQuotesFields.find((f) => f.name === "status")?.values ?? []).includes( "partial_approved", ), ], [ "quotes.parent_quote is a relation to quotes", (() => { const f = vQuotesFields.find((f) => f.name === "parent_quote"); return ( !!f && f.type === "relation" && f.collectionId === vQuotes.id && (f.maxSelect ?? 0) === 1 ); })(), ], [ "quote_items.approved_in_batch is a bool", (() => { const f = vItemsFields.find((f) => f.name === "approved_in_batch"); return !!f && f.type === "bool"; })(), ], [ "quote_items.moved_to_quote is a relation to quotes", (() => { const f = vItemsFields.find((f) => f.name === "moved_to_quote"); return ( !!f && f.type === "relation" && f.collectionId === vQuotes.id && (f.maxSelect ?? 0) === 1 ); })(), ], ]; let allOk = true; for (const [label, ok] of checks) { console.log(` ${ok ? "✓" : "✗"} ${label}`); if (!ok) allOk = false; } if (!allOk) { throw new Error( "[migration:partial-approval] verification failed — see ✗ lines above", ); } console.log("[migration:partial-approval] Migration complete."); } if (require.main === module) { run().catch((err: unknown) => { console.error("[migration:partial-approval] fatal:", err); if (err && typeof err === "object" && "response" in err) { const e = err as { response?: { data?: unknown }; originalError?: { data?: unknown }; data?: unknown; message?: string; }; console.error( "Full error:", JSON.stringify( { message: e.message, responseData: e.response?.data ?? e.originalError?.data ?? e.data, }, null, 2, ), ); } process.exit(1); }); }