WFDASH-WEBAPP/scripts/migrations/2026-07-25-add-ai-insights-fields.ts
SJCoetz 7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00

177 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);
});
}