2026-10-16 23:18:10 +00:00
|
|
|
/**
|
|
|
|
|
* 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: [
|
2026-10-17 00:14:53 +00:00
|
|
|
// 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)",
|
2026-10-16 23:18:10 +00:00
|
|
|
"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);
|
|
|
|
|
});
|