/** * Migration — extend `purchase_orders` for the send-email lifecycle + add * `purchase_order` relation to `invoice_emails` for supplier reply tracking. * * Why: * The Send PO button (operator → Next.js → n8n → SMTP) needs: * - `email_payload` (json) — the outbound composition (subject, body, * cc, bcc, sentAt, supplierReplyMessageId, replyPreview) in one * nested object. Mirrors the existing `quote_items.specifications` * and `pos.specifications` JSON-field convention. One read pulls * everything, one write updates everything. * - `status` values — populate the select with `draft`, `sending`, * `sent`, `mail_failed`, `cancelled`. Currently the select is empty. * - `email_message_id` (text) — RFC 5322 Message-ID of the outbound send, * used as the `In-Reply-To` header on follow-up emails and to match * inbound replies via the `invoice_emails` collection. * - `last_reply_at` (date) — wall-clock of the most recent supplier * reply (when the IMAP poller lands — v2). * - `email_error` (text) — last SMTP failure reason for display in * the mail_failed badge tooltip. * * NOTE: `invoice_emails` (the collection referenced in src/lib/types.ts * for reply tracking) does NOT exist in live PB. Reply tracking is * deferred to a follow-up pass — for v1 we only ship the outbound send * lifecycle. When reply tracking is built, the `email_message_id` field * here will be the `In-Reply-To` anchor. * * Idempotent — skips every field that already exists. * * Run with: * npx tsx scripts/migrations/2026-07-06-purchase-orders-send-fields.ts */ /* eslint-disable */ 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"); const PO_STATUS_VALUES = [ "draft", "sending", "sent", "mail_failed", "cancelled", ]; 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(`[po-send-fields] Authenticated against ${url}`); // ─── purchase_orders ───────────────────────────────────────────────── const poCol = await pb.collections.getOne("purchase_orders"); const poFields = (poCol.fields as Array<{ name: string; type: string; required?: boolean; options?: Record; maxSelect?: number | null; }>).slice(); const additions: Array> = []; let statusFieldMutated = false; if (!poFields.some((f) => f.name === "email_payload")) { additions.push({ name: "email_payload", type: "json", required: false, options: { maxSize: 20000 }, }); } if (!poFields.some((f) => f.name === "email_message_id")) { additions.push({ name: "email_message_id", type: "text", required: false, options: { min: 0, max: 512, pattern: "" }, }); } if (!poFields.some((f) => f.name === "last_reply_at")) { additions.push({ name: "last_reply_at", type: "date", required: false, options: {}, }); } if (!poFields.some((f) => f.name === "email_error")) { additions.push({ name: "email_error", type: "text", required: false, options: { min: 0, max: 1024, pattern: "" }, }); } // Populate `status` select values (if it's a select and values are empty/missing). const statusField = poFields.find((f) => f.name === "status"); if (statusField && statusField.type === "select") { // PB v0.22 stores `values` + `maxSelect` at the field top level, // not nested under `options`. v0.23+ uses nested `options`. This PB // is v0.22 — read the existing shape, mutate at the same level. const have = new Set( (statusField.values as string[] | undefined) ?? ((statusField.options as { values?: string[] } | undefined)?.values ?? []) ); const missing = PO_STATUS_VALUES.filter((v) => !have.has(v)); if (missing.length > 0) { const next = Array.from(new Set([...have, ...missing])); const idx = poFields.findIndex((f) => f.name === "status"); poFields[idx] = { ...statusField, values: next, maxSelect: 1, }; statusFieldMutated = true; console.log( `[po-send-fields] status select — adding ${missing.length} value(s): ${missing.join(", ")}`, ); } } if (additions.length > 0) { poFields.push(...additions); } // Update the collection when EITHER new fields were added OR the status // select values were populated. The previous version of this migration // only persisted on `additions.length > 0`, which silently skipped the // status-values mutation when no fields were added (and left the PO // status field with only `draft/sent/received`, breaking the // "sending" flip in the Send PO route). if (additions.length === 0 && !statusFieldMutated) { console.log( "[po-send-fields] purchase_orders — no new fields or status values to add (skip).", ); } else { await pb.collections.update(poCol.id, { fields: poFields }); console.log( `[po-send-fields] purchase_orders — added ${additions.length} field(s), status mutated=${statusFieldMutated}`, ); } // ─── Verify ────────────────────────────────────────────────────────── const verifyPO = await pb.collections.getOne("purchase_orders"); const summary = (verifyPO.fields as Array<{ name: string; type: string }>) .filter((f) => [ "email_payload", "email_message_id", "last_reply_at", "email_error", "status", ].includes(f.name), ) .map((f) => `${f.name}:${f.type}`) .join(", "); console.log(`[po-send-fields] Post-update purchase_orders → ${summary}`); console.log("[po-send-fields] Migration complete."); } main().catch((err) => { console.error("[po-send-fields] Migration failed:", err); process.exit(1); });