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

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);
});