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
229 lines
No EOL
8.3 KiB
TypeScript
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);
|
|
}); |