Task 3 of the AI order assistant plan. - types: OrderAiQuoteMatch, OrderAiLinePair, OrderItemAiFill, OrderAssistantRunSummary; Order.ai_quote_match, OrderItem.ai_fill, OrderItem.quote_item - ai_settings: order_assistant_enabled (off), _interval_minutes (10, 5-1440), _auto_link (on), _fill_lines (on), _prompt, _last_run_at, _last_run_summary; recordOrderAssistantRun() - migration script + npm run pb:migrate:order-assistant (not run) - audit_log_utils IGNORED_FIELDS: ai_quote_match, ai_fill Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5 KiB
TypeScript
140 lines
5 KiB
TypeScript
/**
|
|
* One-shot migration: fields for the AI order assistant.
|
|
*
|
|
* orders.ai_quote_match json (per-order AI verdict)
|
|
* order_items.ai_fill json (per-line fill record)
|
|
* ai_settings.order_assistant_enabled bool
|
|
* ai_settings.order_assistant_interval_minutes number
|
|
* ai_settings.order_assistant_auto_link bool
|
|
* ai_settings.order_assistant_fill_lines bool
|
|
* ai_settings.order_assistant_prompt text (max 50000)
|
|
* ai_settings.order_assistant_last_run_at text (max 40)
|
|
* ai_settings.order_assistant_last_run_summary json
|
|
*
|
|
* Run with:
|
|
*
|
|
* npm run pb:migrate:order-assistant
|
|
*
|
|
* Idempotent — fields that already exist are skipped. Manual step on the
|
|
* live PB (like select values and pb_hooks).
|
|
*
|
|
* NOTE: PB text fields with `max: 0` cap at 5000 chars, NOT unlimited, so
|
|
* the prompt field gets an explicit max of 50000.
|
|
*
|
|
* The two order-assistant switches default to ON (see the design spec:
|
|
* "Auto-link when sure" is on by default), but a fresh PB bool field
|
|
* starts every existing row at `false`. So the first run that actually
|
|
* adds these fields also seeds the ai_settings row with `true`. A later
|
|
* re-run skips the seeding (the fields already exist), which keeps an
|
|
* operator's deliberate `false` intact.
|
|
*
|
|
* Rollback: delete the fields in the PB admin UI.
|
|
*/
|
|
import "./_env-loader";
|
|
import PocketBase from "pocketbase";
|
|
|
|
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_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;
|
|
|
|
const TAG = "[order-assistant]";
|
|
|
|
type FieldDef = Record<string, unknown> & { name: string };
|
|
|
|
const text = (name: string, max = 500): FieldDef => ({
|
|
name,
|
|
type: "text",
|
|
required: false,
|
|
presentable: false,
|
|
max,
|
|
});
|
|
|
|
const json = (name: string, maxSize: number): FieldDef => ({
|
|
name,
|
|
type: "json",
|
|
required: false,
|
|
presentable: false,
|
|
maxSize,
|
|
});
|
|
|
|
const ORDER_FIELDS: FieldDef[] = [json("ai_quote_match", 65536)];
|
|
|
|
const ORDER_ITEM_FIELDS: FieldDef[] = [json("ai_fill", 16384)];
|
|
|
|
const AI_SETTINGS_FIELDS: FieldDef[] = [
|
|
{ name: "order_assistant_enabled", type: "bool", required: false, presentable: false },
|
|
{ name: "order_assistant_interval_minutes", type: "number", required: false, presentable: false },
|
|
{ name: "order_assistant_auto_link", type: "bool", required: false, presentable: false },
|
|
{ name: "order_assistant_fill_lines", type: "bool", required: false, presentable: false },
|
|
text("order_assistant_prompt", 50000),
|
|
text("order_assistant_last_run_at", 40),
|
|
json("order_assistant_last_run_summary", 20000),
|
|
];
|
|
|
|
/**
|
|
* Add the fields that don't exist yet. Returns true when at least one
|
|
* field was actually added — the caller uses that to decide whether it
|
|
* is the first run (and may seed the default-on switches).
|
|
*/
|
|
async function addMissingFields(
|
|
pb: PocketBase,
|
|
collectionName: string,
|
|
wanted: FieldDef[],
|
|
): Promise<boolean> {
|
|
const collection = await pb.collections.getOne(collectionName);
|
|
const existing = new Set(collection.fields.map((f: { name: string }) => f.name));
|
|
const missing = wanted.filter((f) => !existing.has(f.name));
|
|
|
|
for (const f of wanted) {
|
|
if (existing.has(f.name)) {
|
|
console.log(`${TAG} ${collectionName}.${f.name} already exists — skipping.`);
|
|
}
|
|
}
|
|
if (missing.length === 0) return false;
|
|
|
|
await pb.collections.update(collection.id, {
|
|
fields: [...collection.fields, ...missing],
|
|
});
|
|
for (const f of missing) {
|
|
console.log(`${TAG} ${collectionName}.${f.name} added.`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Turn the two default-on switches on for the existing ai_settings row.
|
|
* Only called on the first run (when the fields were just added).
|
|
*/
|
|
async function seedDefaultOnSwitches(pb: PocketBase): Promise<void> {
|
|
const rows = await pb.collection("ai_settings").getFullList<{ id: string }>();
|
|
if (rows.length === 0) {
|
|
console.log(`${TAG} no ai_settings row yet — the code defaults cover it.`);
|
|
return;
|
|
}
|
|
await pb.collection("ai_settings").update(rows[0].id, {
|
|
order_assistant_auto_link: true,
|
|
order_assistant_fill_lines: true,
|
|
});
|
|
console.log(`${TAG} ai_settings: auto-link + fill-lines defaults turned on.`);
|
|
}
|
|
|
|
async function main() {
|
|
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local).");
|
|
}
|
|
const pb = new PocketBase(PB_URL);
|
|
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
console.log(`${TAG} Authenticated against ${PB_URL}`);
|
|
|
|
await addMissingFields(pb, "orders", ORDER_FIELDS);
|
|
await addMissingFields(pb, "order_items", ORDER_ITEM_FIELDS);
|
|
const settingsAdded = await addMissingFields(pb, "ai_settings", AI_SETTINGS_FIELDS);
|
|
if (settingsAdded) await seedDefaultOnSwitches(pb);
|
|
|
|
console.log(`${TAG} Done.`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`${TAG} Migration failed:`, err);
|
|
process.exit(1);
|
|
});
|