WFDASH-WEBAPP/scripts/migrations/2026-10-17-add-collection-ai.ts

146 lines
4.8 KiB
TypeScript
Raw Normal View History

/**
* One-shot migration: schema for the Collection Confirm AI checks.
*
* collection_doc_reads (new collection) one row per invoice photo the AI read
* order_items.ai_check json (per-line check record)
* stock_purchase_items.ai_check json (per-line check record)
* ai_settings.collection_ai_enabled bool
* ai_settings.collection_ai_prompt text (max 50000)
* ai_settings.collection_ai_last_run_at text (max 40)
* ai_settings.collection_ai_last_run_summary json
*
* Run with:
*
* npm run pb:migrate:collection-ai
*
* Idempotent — an existing collection or field is 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
* every text field gets an explicit max.
*
* Rollback: delete the collection and 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 = "[collection-ai]";
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 DOC_READ_FIELDS: FieldDef[] = [
text("source_collection", 100),
text("source_id", 50),
text("filename", 500),
text("content_hash", 100),
text("status", 20),
text("doc_type", 30),
text("document_no", 200),
text("doc_date", 40),
text("supplier_name", 300),
text("model", 100),
text("error", 2000),
text("read_at", 40),
json("po_refs", 200000),
json("lines", 200000),
json("notes", 200000),
json("findings", 200000),
];
const AI_CHECK_FIELDS: FieldDef[] = [json("ai_check", 50000)];
const AI_SETTINGS_FIELDS: FieldDef[] = [
{ name: "collection_ai_enabled", type: "bool", required: false, presentable: false },
text("collection_ai_prompt", 50000),
text("collection_ai_last_run_at", 40),
json("collection_ai_last_run_summary", 20000),
];
async function addMissingFields(pb: PocketBase, collectionName: string, wanted: FieldDef[]) {
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;
await pb.collections.update(collection.id, {
fields: [...collection.fields, ...missing],
});
for (const f of missing) {
console.log(`${TAG} ${collectionName}.${f.name} added.`);
}
}
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}`);
const all = await pb.collections.getFullList();
if (all.find((c) => c.name === "collection_doc_reads")) {
console.log(`${TAG} collection_doc_reads already exists — skipping.`);
} else {
await pb.collections.create({
name: "collection_doc_reads",
type: "base",
// Reads are open to signed-in users; every write goes through the
// server's admin client.
listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""',
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
...DOC_READ_FIELDS,
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
// Not unique: identical uploads get one row each (per source +
// filename) sharing the hash, and skipped PDFs carry an empty hash.
"CREATE INDEX idx_collection_doc_reads_hash ON collection_doc_reads (content_hash)",
"CREATE INDEX idx_collection_doc_reads_source ON collection_doc_reads (source_collection, source_id, filename)",
],
});
console.log(`${TAG} Created collection_doc_reads.`);
}
await addMissingFields(pb, "order_items", AI_CHECK_FIELDS);
await addMissingFields(pb, "stock_purchase_items", AI_CHECK_FIELDS);
await addMissingFields(pb, "ai_settings", AI_SETTINGS_FIELDS);
console.log(`${TAG} Done.`);
}
main().catch((err) => {
console.error(`${TAG} Migration failed:`, err);
process.exit(1);
});