/* eslint-disable */ /** * Migration: add outbound-email send + failure-tracking fields to the * `quotes` collection, plus two new status values. * * Powers the new "Approve & Send Email" pipeline: * * email_outbound_message_id — RFC 5322 Message-ID stamped by the * SMTP send. Lets the inbound * monitor match future bounces back * to this exact outbound attempt. * email_outbound_attachment_ids — JSON array of `quote_attachments` * record IDs that were sent, so the * retry flow resends the same set * (and only the same set) — not * whatever happens to be attached * at retry time. * email_error — last failure reason. Plain text, * visible in the UI. Empty when the * last attempt succeeded. * sending_started_at — wall-clock when status flipped to * "sending". Lets the UI flag * "stuck for >2min" sends and lets * operators see how long SMTP took. * * Two new status values: * sending — transient. Set by Next.js API when the operator * hits Approve & Send. Cleared by n8n on success * (→ emailed) or failure (→ mail_failed). Visible * in the UI as a spinner pill. * mail_failed — terminal. Set by Next.js API on pre-send * validation failure OR by n8n on SMTP failure. * Retry moves it back to sending. * * The existing `email_failed` status stays — it's used by the inbound * bounce path (an inbound DSN on an outbound quote email becomes * `email_failed`). `mail_failed` is for *send* failures (operator or * SMTP side); `email_failed` is for *delivery* failures (recipient * side, surfaced via DSN later). Keeping them separate makes the * dashboard useful: "X failed to send" vs "Y bounced after sending". * * Idempotent — safe to re-run. * * 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-outbound-email-fields.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 {} } loadEnvFile(".env"); loadEnvFile(".env.local"); const QUOTES_COLLECTION_ID = "pbc_2527524235"; 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); const coll = await pb.collections.getOne(QUOTES_COLLECTION_ID); const fields = ((coll as { fields?: Array> }).fields ?? []) as Array>; const planned: string[] = []; // ── 1. Status enum — add `sending` + `mail_failed` if missing ──────────── const statusField = fields.find((f) => f.name === "status"); if (!statusField) { console.error("Could not find `status` field on quotes collection."); process.exit(1); } const statusValues = (statusField.values as string[]) || []; const wantStatusValues = [...statusValues]; for (const v of ["sending", "mail_failed"]) { if (!wantStatusValues.includes(v)) { wantStatusValues.push(v); planned.push(`add status value "${v}"`); } else { planned.push(`status value "${v}" already present — skip`); } } statusField.values = wantStatusValues; // ── 2. New text / date fields ─────────────────────────────────────────── const newFields: Array> = [ { help: "RFC 5322 Message-ID stamped on the outbound email. Lets the inbound monitor match future bounce/DSN emails back to the specific outbound attempt.", hidden: false, id: "text_email_outbound_message_id", max: 500, min: 0, name: "email_outbound_message_id", pattern: "", presentable: false, required: false, system: false, type: "text", }, { help: "JSON array of quote_attachments record IDs sent with the outbound email. Captured at send time so retry sends the same set, not whatever happens to be attached at retry.", hidden: false, id: "text_email_outbound_attachment_ids", max: 5000, min: 0, name: "email_outbound_attachment_ids", pattern: "", presentable: false, required: false, system: false, type: "text", }, { help: "Last failure reason from the outbound send. Cleared on retry. Visible in the UI banner and the quote card timeline.", hidden: false, id: "text_email_error", max: 5000, min: 0, name: "email_error", pattern: "", presentable: false, required: false, system: false, type: "text", }, { help: "When status flipped to `sending`. Lets the UI flag stuck sends (>2 min) and operators measure SMTP turnaround.", hidden: false, id: "date_sending_started_at", max: "", min: "", name: "sending_started_at", presentable: false, required: false, system: false, type: "date", onDelete: false, }, ]; for (const def of newFields) { const name = def.name as string; if (fields.some((f) => f.name === name)) { planned.push(`field "${name}" already present — skip`); } else { fields.push(def); planned.push(`add field "${name}"`); } } const willChange = planned.some((p) => !p.includes("skip")); if (!willChange) { console.log("Schema already up to date."); const afterStatus = ( (statusField.values as string[]) || [] ).join(", "); console.log(`Verified status values: ${afterStatus}`); console.log( "Verified fields:", fields.map((f) => f.name).join(", ") ); return; } console.log("Planned changes:"); for (const p of planned) console.log(` • ${p}`); await pb.collections.update(coll.id, { fields }); const after = await pb.collections.getOne(coll.id); const afterFields = ((after as { fields?: Array> }).fields ?? []) as Array>; const afterStatus = ((afterFields.find((f) => f.name === "status")?.values as string[]) || []); const needed = [ "sending", "mail_failed", "email_outbound_message_id", "email_outbound_attachment_ids", "email_error", "sending_started_at", ]; console.log("\nMigration complete."); for (const n of needed) { if (n === "sending" || n === "mail_failed") { console.log(` status value "${n}" present:`, afterStatus.includes(n)); } else { console.log(` field "${n}" present:`, afterFields.some((f) => f.name === n)); } } if (needed.some((n) => (n === "sending" || n === "mail_failed" ? !afterStatus.includes(n) : !afterFields.some((f) => f.name === n)))) { console.error( "\n!!! Migration verification failed. Please inspect manually." ); process.exit(1); } console.log( "\nAll good. The new send-email pipeline can now persist outbound metadata and report failures with a reason." ); } main().catch((err) => { console.error("Migration failed:", err); process.exit(1); });