178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
|
|
/**
|
||
|
|
* Migration — 2026-07-25-add-ai-insights-fields.ts
|
||
|
|
*
|
||
|
|
* Adds the four JSON fields the n8n AI agent writes to when it generates
|
||
|
|
* insights for a quote/order or for an individual line item.
|
||
|
|
*
|
||
|
|
* ## Why JSON, not a typed schema
|
||
|
|
*
|
||
|
|
* The AI produces whatever it can find — the shape is fluid per record
|
||
|
|
* (sometimes we have historical pricing, sometimes not; sometimes the
|
||
|
|
* model surfaces a duplicate warning, sometimes a margin one). Locking
|
||
|
|
* the schema down fights the model. We keep the field as a single
|
||
|
|
* `json` blob and let the UI be a smart renderer (see
|
||
|
|
* `src/components/ai-insights-renderer.tsx`) that handles whatever
|
||
|
|
* keys come back.
|
||
|
|
*
|
||
|
|
* The only contract between n8n and the UI is: a top-level `summary`
|
||
|
|
* string. Everything else is opportunistic.
|
||
|
|
*
|
||
|
|
* ## The four fields
|
||
|
|
*
|
||
|
|
* - quotes.ai_insights (json) — document-level card
|
||
|
|
* - orders.ai_insights (json) — document-level card
|
||
|
|
* - quote_items.ai_item_insights (json) — line-item popover
|
||
|
|
* - order_items.ai_item_insights (json) — line-item popover
|
||
|
|
*
|
||
|
|
* Snapshot-once: n8n writes the field when the parent record is
|
||
|
|
* created. The UI never overwrites it; the "Regenerate" button on the
|
||
|
|
* card triggers a re-run of the n8n flow which then PATCHes the same
|
||
|
|
* field.
|
||
|
|
*
|
||
|
|
* Idempotent — safe to re-run. Skips fields that already exist.
|
||
|
|
*
|
||
|
|
* Run with:
|
||
|
|
* npx tsx scripts/migrations/2026-07-25-add-ai-insights-fields.ts
|
||
|
|
*/
|
||
|
|
import PocketBase from "pocketbase";
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { resolve } from "node:path";
|
||
|
|
|
||
|
|
function loadEnvFile(name: string) {
|
||
|
|
try {
|
||
|
|
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||
|
|
for (const line of raw.split("\n")) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||
|
|
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||
|
|
if (m && !process.env[m[1]]) {
|
||
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* fine — rely on shell env */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
loadEnvFile(".env");
|
||
|
|
loadEnvFile(".env.local");
|
||
|
|
|
||
|
|
function getAdminPocketBase(): PocketBase {
|
||
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||
|
|
const email = process.env.PB_ADMIN_EMAIL;
|
||
|
|
const password = process.env.PB_ADMIN_PASSWORD;
|
||
|
|
if (!url || !email || !password) {
|
||
|
|
throw new Error(
|
||
|
|
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (sourced from .env.local).",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return new PocketBase(url);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function authedPb(): Promise<PocketBase> {
|
||
|
|
const pb = getAdminPocketBase();
|
||
|
|
await pb
|
||
|
|
.collection("_superusers")
|
||
|
|
.authWithPassword(
|
||
|
|
process.env.PB_ADMIN_EMAIL!,
|
||
|
|
process.env.PB_ADMIN_PASSWORD!,
|
||
|
|
);
|
||
|
|
return pb;
|
||
|
|
}
|
||
|
|
|
||
|
|
type AnyField = {
|
||
|
|
name: string;
|
||
|
|
type?: string;
|
||
|
|
required?: boolean;
|
||
|
|
maxSize?: number;
|
||
|
|
[k: string]: unknown;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PB's `json` field shape: name + type + optional maxSize (in bytes).
|
||
|
|
* No `options` wrapper — same flat shape as `select` / `file` fields,
|
||
|
|
* see memory gotcha "PB select fields… create-collection API uses
|
||
|
|
* `fields`, not `schema`".
|
||
|
|
*/
|
||
|
|
function jsonField(name: string): AnyField {
|
||
|
|
return {
|
||
|
|
name,
|
||
|
|
type: "json",
|
||
|
|
required: false,
|
||
|
|
// 64KB ceiling — way more than the AI will write, but small enough
|
||
|
|
// to keep PB's per-field validation snappy. Bump if the model ever
|
||
|
|
// produces a legitimate >64KB insight blob.
|
||
|
|
maxSize: 65_536,
|
||
|
|
presentable: false,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const TARGETS: Array<{ collection: string; field: AnyField }> = [
|
||
|
|
{ collection: "quotes", field: jsonField("ai_insights") },
|
||
|
|
{ collection: "orders", field: jsonField("ai_insights") },
|
||
|
|
{ collection: "quote_items", field: jsonField("ai_item_insights") },
|
||
|
|
{ collection: "order_items", field: jsonField("ai_item_insights") },
|
||
|
|
];
|
||
|
|
|
||
|
|
export async function run(): Promise<void> {
|
||
|
|
const pb = await authedPb();
|
||
|
|
|
||
|
|
// Build a per-collection name → collectionId map once, so we can
|
||
|
|
// resolve by name even though the SDK wants the id.
|
||
|
|
const collections = await pb.collections.getFullList();
|
||
|
|
const byName = new Map(collections.map((c) => [c.name, c]));
|
||
|
|
|
||
|
|
for (const { collection, field } of TARGETS) {
|
||
|
|
const col = byName.get(collection);
|
||
|
|
if (!col) {
|
||
|
|
console.warn(
|
||
|
|
`[migration:ai-insights] skip — collection "${collection}" not found`,
|
||
|
|
);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const existing = ((col.fields ?? []) as AnyField[]).map((f) => f.name);
|
||
|
|
if (existing.includes(field.name)) {
|
||
|
|
console.log(
|
||
|
|
`[migration:ai-insights] ${collection}.${field.name} already exists — skip`,
|
||
|
|
);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const next = [...((col.fields ?? []) as AnyField[]), field];
|
||
|
|
await pb.collections.update(col.id, { fields: next });
|
||
|
|
console.log(
|
||
|
|
`[migration:ai-insights] ✓ added ${collection}.${field.name} (json, 64KB)`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify
|
||
|
|
console.log("\n[migration:ai-insights] Verifying...");
|
||
|
|
const fresh = await pb.collections.getFullList();
|
||
|
|
const freshByName = new Map(fresh.map((c) => [c.name, c]));
|
||
|
|
const checks: Array<[string, boolean]> = [];
|
||
|
|
for (const { collection, field } of TARGETS) {
|
||
|
|
const c = freshByName.get(collection);
|
||
|
|
const f = ((c?.fields ?? []) as AnyField[]).find((x) => x.name === field.name);
|
||
|
|
checks.push([
|
||
|
|
`${collection}.${field.name} is json`,
|
||
|
|
f?.type === "json",
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
let allOk = true;
|
||
|
|
for (const [label, ok] of checks) {
|
||
|
|
console.log(` ${ok ? "✓" : "✗"} ${label}`);
|
||
|
|
if (!ok) allOk = false;
|
||
|
|
}
|
||
|
|
if (!allOk) {
|
||
|
|
throw new Error("[migration:ai-insights] verification failed");
|
||
|
|
}
|
||
|
|
console.log("[migration:ai-insights] Migration complete.");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (require.main === module) {
|
||
|
|
run().catch((err) => {
|
||
|
|
console.error("[migration:ai-insights] fatal:", err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|
||
|
|
}
|