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
212 lines
6.8 KiB
TypeScript
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);
|
|
});
|