WFDASH-WEBAPP/scripts/migrations/2026-06-23-add-quote-outbound-email-fields.ts
SJCoetz 588e9762c2 feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 22:57:01 +02:00

237 lines
No EOL
8.2 KiB
TypeScript

/* 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<Record<string, unknown>> }).fields ??
[]) as Array<Record<string, unknown>>;
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<Record<string, unknown>> = [
{
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<Record<string, unknown>> }).fields ??
[]) as Array<Record<string, unknown>>;
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);
});