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
237 lines
No EOL
8.2 KiB
TypeScript
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);
|
|
}); |