WFDASH-WEBAPP/scripts/migrations/add-quote-items-user-edited.ts
SJCoetz 2c6155abbb feat: add AI quote item rewriter and supporting UI/UX changes
- Add `user_edited` field to QuoteItem type to gate AI rewrites on user-edited content
- Add idempotent migration script to add and backfill the `user_edited` field across quote items
- Replace plain Select components with searchable SupplierSearch combobox across order and quote pages
- Add working days calculation utility and aged quote warning badges for overdue quotes
- Implement expandable row previews in the quotes list view
- Add full AI rewrite workflow: backend API endpoint, frontend modal, and n8n workflow definition
- Update package scripts for the new migration
2026-06-22 18:29:59 +02:00

113 lines
3.7 KiB
TypeScript

/**
* One-shot migration: add `user_edited` to the live `quote_items`
* collection (idempotent) and backfill existing rows.
*
* Run with:
*
* npx tsx scripts/migrations/add-quote-items-user-edited.ts
*
* Backfill rule:
* - Items with a non-empty description that were UPDATED after
* creation (i.e. a human saved them via the process page) are
* set to `user_edited = true`.
* - Items that were created and never updated (auto-extracted from
* the email pipeline) are left as `user_edited = false`. The
* rewriter gate will refuse them until the user edits + saves
* them in the process page — which is the intended behaviour.
*
* The script is safe to run multiple times.
*/
import PocketBase from "pocketbase";
const PB_URL = process.env.PB_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`[user_edited] Authenticated against ${PB_URL}`);
// 1. Ensure the field exists on the collection.
const collection = await pb.collections.getOne("quote_items");
const hasField = collection.fields.some(
(f: { name: string }) => f.name === "user_edited"
);
if (!hasField) {
console.log("[user_edited] Field missing — adding it to quote_items.");
await pb.collections.update(collection.id, {
fields: [
...collection.fields,
{
name: "user_edited",
type: "bool",
required: false,
presentable: false,
},
],
});
console.log("[user_edited] Field added.");
} else {
console.log("[user_edited] Field already exists — skipping schema update.");
}
// 2. Backfill. Pull in pages of 200 so we don't OOM on large tables.
let page = 1;
let scanned = 0;
let updated = 0;
let skippedAlreadyTrue = 0;
let skippedMachine = 0;
while (true) {
const batch = await pb.collection("quote_items").getList(page, 200, {
fields: "id,description,created,updated,user_edited",
});
if (batch.items.length === 0) break;
for (const item of batch.items) {
scanned++;
if (item.user_edited === true) {
skippedAlreadyTrue++;
continue;
}
// Heuristic: a non-empty description AND a meaningful gap
// between created and updated timestamps (PB stores ISO; Date
// diff > 1000ms is enough) means a human saved this row at
// least once. Empty description or created==updated means it's
// still in its auto-extracted state.
const hasDesc =
typeof item.description === "string" && item.description.trim().length > 0;
const humanSaved =
hasDesc &&
new Date(item.updated).getTime() - new Date(item.created).getTime() >
1000;
if (!humanSaved) {
skippedMachine++;
continue;
}
await pb.collection("quote_items").update(item.id, { user_edited: true });
updated++;
}
if (batch.page >= batch.totalPages) break;
page++;
}
console.log(`[user_edited] Scanned ${scanned} items.`);
console.log(`[user_edited] Flipped to true: ${updated}`);
console.log(`[user_edited] Already true: ${skippedAlreadyTrue}`);
console.log(`[user_edited] Left as false (machine / no description): ${skippedMachine}`);
console.log("[user_edited] Done.");
}
main().catch((err) => {
console.error("[user_edited] Migration failed:", err);
process.exit(1);
});