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
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration: add `direction` field to `quote_attachments` collection.
|
|
*
|
|
* Three values:
|
|
* - "inbound" — file came in with the original RFQ email
|
|
* - "outbound" — file the operator uploaded to send with the quote
|
|
* - "generated" — PDF auto-generated by the /api/v1/quotes/[id]/pdf
|
|
* endpoint and saved back to this collection
|
|
*
|
|
* Backfill rules for existing rows:
|
|
* - email_message_id is set AND non-empty → "inbound"
|
|
* - email_message_id is empty AND kind == "pdf" → "generated"
|
|
* - everything else (operator pre-staged files, etc.) → "outbound"
|
|
*
|
|
* After this migration the UI on the quote detail page can filter by
|
|
* direction=outbound to show the "files to send with this quote" card,
|
|
* separate from the inbound attachments visible in the email card.
|
|
*
|
|
* Idempotent: running twice is safe — it skips the field-create if
|
|
* `direction` is already present, and updates each row's `direction`
|
|
* only if it differs from what we'd backfill (so we don't churn
|
|
* rows that were already set explicitly by the UI).
|
|
*
|
|
* 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-attachments-direction.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")) {
|
|
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);
|
|
|
|
// Look up the collection by name so this migration works against any
|
|
// environment (the local snapshot doesn't include quote_attachments
|
|
// and the live id may differ between PB instances).
|
|
const coll = await pb.collections.getFirstListItem(`name="quote_attachments"`);
|
|
const fields = ((coll as { fields?: Array<Record<string, unknown>> }).fields ?? []) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
|
|
type FieldDef = Record<string, unknown>;
|
|
|
|
const DIRECTION_FIELD_ID = "sel_quote_attachments_direction";
|
|
|
|
const planned: string[] = [];
|
|
|
|
if (fields.some((f) => f.name === "direction")) {
|
|
planned.push(`field "direction" already present — skip`);
|
|
} else {
|
|
const newField: FieldDef = {
|
|
help:
|
|
"inbound: came in with the original RFQ email. " +
|
|
"outbound: operator uploaded to send with the quote. " +
|
|
"generated: PDF rendered by /api/v1/quotes/[id]/pdf.",
|
|
hidden: false,
|
|
id: DIRECTION_FIELD_ID,
|
|
maxSelect: 1,
|
|
name: "direction",
|
|
presentable: false,
|
|
required: false,
|
|
system: false,
|
|
type: "select",
|
|
values: ["inbound", "outbound", "generated"],
|
|
};
|
|
fields.push(newField);
|
|
planned.push(`add "direction" select field (inbound | outbound | generated)`);
|
|
}
|
|
|
|
const willChangeSchema = planned.some((p) => !p.includes("skip"));
|
|
if (willChangeSchema) {
|
|
console.log("Planned schema changes:");
|
|
for (const p of planned) console.log(` • ${p}`);
|
|
await pb.collections.update(coll.id, { fields });
|
|
} else {
|
|
console.log("Schema already up to date — skipping field create.");
|
|
}
|
|
|
|
// Backfill `direction` on existing rows. We only touch rows that
|
|
// don't have it set yet, OR rows whose value disagrees with what the
|
|
// backfill rule would compute — leaves user-edited values alone.
|
|
console.log("\nBackfilling existing rows…");
|
|
const all = await pb.collection("quote_attachments").getFullList<{
|
|
id: string;
|
|
direction?: string | null;
|
|
email_message_id?: string;
|
|
kind?: string;
|
|
}>({ perPage: 500 });
|
|
|
|
let touched = 0;
|
|
let skipped = 0;
|
|
for (const row of all) {
|
|
const want = computeDirection(row);
|
|
if (row.direction === want) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
await pb.collection("quote_attachments").update(row.id, { direction: want });
|
|
touched++;
|
|
}
|
|
|
|
console.log(` • rows scanned: ${all.length}`);
|
|
console.log(` • rows updated: ${touched}`);
|
|
console.log(` • rows skipped (already correct): ${skipped}`);
|
|
|
|
const after = await pb.collections.getOne(coll.id);
|
|
const afterFields = ((after as { fields?: Array<Record<string, unknown>> }).fields ?? []) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
const directionField = afterFields.find((f) => f.name === "direction");
|
|
const values = (directionField?.values as string[] | undefined) ?? [];
|
|
|
|
console.log("\nMigration complete.");
|
|
console.log(" direction field present: ", afterFields.some((f) => f.name === "direction"));
|
|
console.log(' includes "inbound": ', values.includes("inbound"));
|
|
console.log(' includes "outbound": ', values.includes("outbound"));
|
|
console.log(' includes "generated": ', values.includes("generated"));
|
|
|
|
if (
|
|
!afterFields.some((f) => f.name === "direction") ||
|
|
!values.includes("inbound") ||
|
|
!values.includes("outbound") ||
|
|
!values.includes("generated")
|
|
) {
|
|
console.error("\n!!! Migration verification failed. Please inspect manually.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("\nAll good. The Outbound Attachments card on /quotes/[id] will now work.");
|
|
}
|
|
|
|
/**
|
|
* Backfill rule for one row. Exported via a small wrapper so the
|
|
* migration script stays flat; matches the spec in the file header.
|
|
*/
|
|
function computeDirection(row: {
|
|
email_message_id?: string;
|
|
kind?: string;
|
|
}): "inbound" | "outbound" | "generated" {
|
|
const mid = (row.email_message_id || "").trim();
|
|
if (mid) return "inbound";
|
|
if ((row.kind || "").toLowerCase() === "pdf") return "generated";
|
|
return "outbound";
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Migration failed:", err);
|
|
process.exit(1);
|
|
});
|