WFDASH-WEBAPP/scripts/migrations/2026-08-08-add-quotes-ai-status.ts
SJCoetz 63048e51c9 feat: add AI quote processing pipeline and admin settings UI
Add complete end-to-end AI-powered quote extraction workflow:
- Disable PB SDK auto-cancellation to support parallel batch requests
- Add required runtime dependencies for AI processing and document parsing
- Implement AI settings backend, operator UI, and REST API routes
- Add quote ingestion, insight population, and manual re-run AI endpoints
- Build service layers for quote items, AI settings, and structured item insights
- Fix attachment upload handling and quote item history blob parsing
- Add supporting migrations, smoke tests, and shared utility functions
- Export previously private helper functions for cross-service reuse
2026-08-09 16:17:29 +02:00

229 lines
No EOL
8.3 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add `ai_status` + `ai_error` + `ai_processed_at` to quotes.
*
* Why this exists
* ───────────────
* The quote-intake pipeline is being split in two:
*
* 1. Ingestion (fast) — accepts the raw email payload, creates a quote
* row with `ai_status = "pending"`, uploads attachments. Returns
* immediately so n8n's webhook doesn't time out on a slow AI call.
*
* 2. AI processing (async) — a separate cron endpoint scans quotes
* with `ai_status = "pending"` or `"failed"`, runs Claude against
* the body + attachments, writes `quote_items`, flips status.
*
* Three new fields are needed on `quotes`:
*
* ai_status select single-select lifecycle flag
* pending → processing → complete
* ↓
* failed (retry-eligible)
* not_applicable for manually-created
* quotes that never went through email.
*
* ai_error text Last AI error message — surfaced in the
* operator UI when ai_status='failed'.
*
* ai_processed_at date Timestamp of the most recent AI run, for
* the "last attempted" badge in the UI.
*
* Why this reads pb-exports/pb_schema-2.json
* ───────────────────────────────────────────
* PB 0.26's `collections.getOne()` strips the `options.values` array
* from select fields created under PB 0.22 or earlier — they come
* back as `options: undefined`. Worse, when re-PUTting the fields
* array, PB validates EVERY field (not just the new ones), so any
* existing select field with a missing `values` array kills the whole
* update with `values: validation_required`.
*
* The schema export is the only place the full definition survives.
* We source the field list from the export, append our additions,
* and PUT the whole array back. Select fields in PB 0.26 on this
* instance still accept the legacy PB 0.22 shape (`values` at the
* top level of the field, not nested under `options`), so we pass
* them through unchanged.
*
* Schema
* ──────
* quotes.ai_status select (new, nullable, single-select)
* quotes.ai_error text (new, max 4900, nullable)
* quotes.ai_processed_at date (new, nullable)
*
* Idempotent — safe to re-run. Subsequent runs print "already present,
* skipping."
*
* Run with:
* npx tsx scripts/migrations/2026-08-08-add-quotes-ai-status.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 {
// missing file — fine
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const AI_STATUS_VALUES = [
"pending",
"processing",
"complete",
"failed",
"not_applicable",
] as const;
const SCHEMA_EXPORT_PATH = resolve(
process.cwd(),
"pb-exports",
"pb_schema-2.json",
);
async function main() {
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 and PB_ADMIN_PASSWORD.",
);
}
// ─── 1. Load the full schema from the export so existing select
// field values aren't lost when we PUT the whole array back. ──
let schemaExport: Array<Record<string, unknown>>;
try {
schemaExport = JSON.parse(readFileSync(SCHEMA_EXPORT_PATH, "utf8")) as Array<
Record<string, unknown>
>;
} catch {
throw new Error(
`Could not read ${SCHEMA_EXPORT_PATH}. The export is the source of truth for select field values.`,
);
}
const quotesExport = schemaExport.find((c) => c.name === "quotes");
if (!quotesExport) {
throw new Error('Schema export does not contain a "quotes" collection.');
}
const existingFields = (quotesExport.fields ?? []) as Array<
Record<string, unknown>
>;
const existingNames = new Set(existingFields.map((f) => f.name as string));
const pb = new PocketBase(url);
await pb.admins.authWithPassword(email, password);
const quotesLive = await pb.collections.getOne("quotes");
const existingIndexes = (quotesLive.indexes ?? []) as string[];
// ─── 2. Add the three fields ────────────────────────────────────
const additions: Array<Record<string, unknown>> = [];
if (!existingNames.has("ai_status")) {
additions.push({
name: "ai_status",
type: "select",
required: false,
presentable: false,
system: false,
hidden: false,
help: "AI processing state for email-intake quotes. pending|processing|complete|failed|not_applicable",
// PB 0.26 on this instance still accepts the legacy PB 0.22
// shape (`values` at the top level) for select fields. Keeping
// it consistent with the existing `status` and
// `company_letterhead` fields.
values: [...AI_STATUS_VALUES],
maxSelect: 1,
});
} else {
console.log("quotes.ai_status already present, skipping.");
}
if (!existingNames.has("ai_error")) {
additions.push({
name: "ai_error",
type: "text",
required: false,
presentable: false,
system: false,
hidden: false,
help: "Last AI error message — surfaced in the operator UI when ai_status='failed'.",
options: { max: 4900, min: 0, pattern: "" },
});
} else {
console.log("quotes.ai_error already present, skipping.");
}
if (!existingNames.has("ai_processed_at")) {
additions.push({
name: "ai_processed_at",
type: "date",
required: false,
presentable: false,
system: false,
hidden: false,
help: "Timestamp of the most recent AI run.",
options: { min: "", max: "" },
});
} else {
console.log("quotes.ai_processed_at already present, skipping.");
}
if (additions.length === 0) {
console.log("All three AI fields already present on quotes — nothing to add.");
} else {
// Pass the existing fields through unchanged (they're already in
// the shape PB accepts on this instance). Append our additions.
await pb.collections.update(quotesLive.id, {
fields: [...existingFields, ...additions],
});
console.log(
`Added quotes fields: ${additions.map((f) => f.name).join(", ")}`,
);
}
// ─── 3. Index on `ai_status` ─────────────────────────────────────
// The AI processor's main query is:
// ai_status = "pending" OR ai_status = "failed"
// Without an index this is a full table scan on every cron tick.
// ~8.5k quotes today; still cheap, but the index is cheap insurance
// against growth and matches the `idx_quotes_status` pattern from
// the 2026-08-04 quotes-status-index migration.
const INDEX_NAME = "idx_quotes_ai_status";
const INDEX_SQL = `CREATE INDEX \`${INDEX_NAME}\` ON \`quotes\` (\`ai_status\`)`;
if (!existingIndexes.includes(INDEX_SQL)) {
await pb.collections.update(quotesLive.id, {
indexes: [...existingIndexes, INDEX_SQL],
});
console.log("Added quotes.ai_status index (idx_quotes_ai_status).");
} else {
console.log("quotes.ai_status index already present, skipping.");
}
console.log("\nMigration complete.");
console.log(
"Next step: implement src/lib/server/quotes-from-email-service.ts (ingestion, sets ai_status='pending'),",
);
console.log(
"then src/lib/server/quotes-ai-processor.ts (background processor, flips ai_status='complete').",
);
}
main().catch((error) => {
console.error("[add-quotes-ai-status] failed:", error);
process.exit(1);
});