- 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
113 lines
3.7 KiB
TypeScript
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);
|
|
});
|