WFDASH-WEBAPP/scripts/migrations/2026-07-06-quote-attachments-quote-item-and-kind.ts

115 lines
3.9 KiB
TypeScript
Raw Normal View History

/**
* Migration: 2026-07-06 — quote_attachments: add quote_item relation + kind field
*
* What it does:
* 1. Adds `quote_item` relation to `quote_attachments` (nullable, single,
* collectionId = quote_items). Lets a per-line attachment live alongside
* per-quote attachments on the same collection instead of needing a
* separate `quote_item_attachments` table.
*
* 2. Adds `kind` select field (pdf / image / doc / other) to
* `quote_attachments`. The TypeScript interface in `src/lib/types.ts`
* already declares `kind` (and `QuoteAttachmentKind`) — the v5 attachment
* workflow writes it on upload. Live PB never got the migration that adds
* the field, so existing rows have `kind = null`. The migration is
* idempotent: it skips when the field already exists.
*
* Why both at once:
* The order process page surfaces matched quote + per-line attachments so
* the buyer can verify they're ordering the right SKU from the right
* supplier. Without `quote_item` we can't link an attachment to a specific
* line; without `kind` we can't pick the right thumbnail (PDF vs image vs
* doc icon). Both are needed for the inline preview UI to be usable.
*
* Idempotency:
* Both field adds are skipped if a field with that name already exists.
* Tested collection ids are discovered by name from PB on every run, so
* this migration works across environments.
*
* Run:
* set -a && source .env.local && set +a && \
* npx tsx scripts/migrations/2026-07-06-quote-attachments-quote-item-and-kind.ts
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
type PbField = {
id?: string;
name: string;
type: string;
collectionId?: string;
values?: string[];
required?: boolean;
maxSelect?: number;
minSelect?: number;
cascadeDelete?: boolean;
[k: string]: unknown;
};
async function main() {
const pb = await getAdminPocketBase();
// Resolve by name — works across PB instances even if the id shifts.
const coll = await pb.collections.getFirstListItem(`name="quote_attachments"`);
const quoteItemsColl = await pb.collections.getFirstListItem(`name="quote_items"`);
const fields = ((coll as unknown as { fields?: PbField[] }).fields ?? []) as PbField[];
const existingNames = new Set(fields.map((f) => f.name));
const planned: string[] = [];
if (existingNames.has("quote_item")) {
planned.push(`field "quote_item" already present — skip`);
} else {
fields.push({
hidden: false,
name: "quote_item",
presentable: false,
required: false,
system: false,
type: "relation",
collectionId: quoteItemsColl.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
});
planned.push(`add "quote_item" relation field (→ quote_items, nullable)`);
}
if (existingNames.has("kind")) {
planned.push(`field "kind" already present — skip`);
} else {
fields.push({
hidden: false,
name: "kind",
presentable: false,
required: false,
system: false,
type: "select",
maxSelect: 1,
values: ["pdf", "image", "doc", "other"],
});
planned.push(`add "kind" select field (pdf | image | doc | other)`);
}
const willChange = planned.some((p) => !p.includes("skip"));
console.log("Planned schema changes:");
for (const p of planned) console.log(` • ${p}`);
if (willChange) {
await pb.collections.update(coll.id, { fields });
console.log("\n[done] schema updated.");
} else {
console.log("\n[done] schema already up to date — no changes written.");
}
// Read back to confirm what landed.
const after = (await pb.collections.getOne(coll.id)) as { fields?: PbField[] };
const afterNames = (after.fields ?? []).map((f) => f.name).join(", ");
console.log(`\nquote_attachments fields now: ${afterNames}`);
}
main().catch((err) => {
console.error("[fatal]", err);
process.exit(1);
});