WFDASH-WEBAPP/scripts/migrations/2026-06-23-add-quote-template-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

212 lines
6.8 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add v5 quote fields to the `quotes` collection.
*
* Idempotent — running twice is safe. Each step checks whether the
* field already exists (by `name`) before adding it; for the `status`
* select field, it merges "template" into the existing values array
* without disturbing the rest.
*
* 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-template-fields.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// Load .env / .env.local if present (tsx doesn't auto-load them)
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
// Skip comments and blank lines
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 {
// file not present — fine, rely on shell env
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
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 QUOTES_COLLECTION_ID = "pbc_2527524235"; // from pb_schema (1).json
const collection = await pb.collections.getOne(QUOTES_COLLECTION_ID);
const fields = ((collection as { fields?: Array<Record<string, unknown>> }).fields ?? []) as Array<
Record<string, unknown>
>;
type FieldDef = Record<string, unknown>;
const newFields: FieldDef[] = [
{
help: "Quote date printed on the PDF. If blank, the PDF falls back to quote.created.",
hidden: false,
id: "date_quote_date",
max: "",
min: "",
name: "quote_date",
presentable: false,
required: false,
system: false,
type: "date",
},
{
help: "Validity period in days (e.g. 30, 60). If blank, defaults to 30.",
hidden: false,
id: "number_validity_days",
max: null,
min: null,
name: "validity_days",
onlyInt: true,
presentable: false,
required: false,
system: false,
type: "number",
},
{
help: "True if this quote is a reusable template (scaffolding, not a real customer quote).",
hidden: false,
id: "bool_is_template",
name: "is_template",
presentable: false,
required: false,
system: false,
type: "bool",
},
{
cascadeDelete: false,
collectionId: QUOTES_COLLECTION_ID,
help: "If this template was cloned from a real quote, the source quote id.",
hidden: false,
id: "relation_template_source",
maxSelect: 1,
minSelect: 0,
name: "template_source_id",
presentable: false,
required: false,
system: false,
type: "relation",
},
{
cascadeDelete: false,
collectionId: QUOTES_COLLECTION_ID,
help: "If this quote was generated from a template, the template quote id.",
hidden: false,
id: "relation_generated_from_template",
maxSelect: 1,
minSelect: 0,
name: "generated_from_template_id",
presentable: false,
required: false,
system: false,
type: "relation",
},
{
help: "User-set follow-up reminder date. Dashboard widget shows quotes where follow_up_due <= today and follow-up is still pending.",
hidden: false,
id: "date_follow_up_due",
max: "",
min: "",
name: "follow_up_due",
presentable: false,
required: false,
system: false,
type: "date",
},
];
const planned: string[] = [];
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 statusField = fields.find((f) => f.name === "status");
if (statusField) {
const values = (statusField.values as string[] | undefined) ?? [];
if (values.includes("template")) {
planned.push(`status already includes "template" — skip`);
} else {
statusField.values = [...values, "template"];
planned.push(`add "template" to status select values`);
}
}
const willChange = planned.some((p) => !p.includes("skip"));
if (!willChange) {
console.log("Nothing to do — schema already up to date.");
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(QUOTES_COLLECTION_ID, { fields });
const after = await pb.collections.getOne(QUOTES_COLLECTION_ID);
const afterFields = ((after as { fields?: Array<Record<string, unknown>> }).fields ?? []) as Array<
Record<string, unknown>
>;
const afterNames = afterFields.map((f) => f.name);
const statusAfter = afterFields.find((f) => f.name === "status");
const statusVals = (statusAfter?.values as string[] | undefined) ?? [];
console.log("\nMigration complete. Field names now present:");
console.log(" " + afterNames.join(", "));
console.log("\nSanity-check:");
console.log(" quote_date present: ", afterNames.includes("quote_date"));
console.log(" validity_days present: ", afterNames.includes("validity_days"));
console.log(" is_template present: ", afterNames.includes("is_template"));
console.log(" template_source_id present: ", afterNames.includes("template_source_id"));
console.log(" generated_from_template_id: ", afterNames.includes("generated_from_template_id"));
console.log(" follow_up_due present: ", afterNames.includes("follow_up_due"));
console.log(' status includes "template": ', statusVals.includes("template"));
if (
!afterNames.includes("quote_date") ||
!afterNames.includes("validity_days") ||
!afterNames.includes("is_template") ||
!afterNames.includes("follow_up_due") ||
!statusVals.includes("template")
) {
console.error("\n!!! Migration verification failed. Please inspect manually.");
process.exit(1);
}
console.log("\nAll good. Reload the dashboard — the 400s should be gone.");
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});