feat(api/v1/items): add item intel refresh route

Add a new Next.js POST API endpoint that regenerates AI item insights for specific quote_item or order_item records. Support dry-run mode to preview insights without persisting changes, validate input parameters and existing records to return clear error responses, and use admin-level PocketBase access for trusted server-side callers including n8n webhooks and batch cron jobs.
This commit is contained in:
Dave 2026-08-11 11:28:51 +02:00
parent 397df8e2b2
commit 7d4e4c34f2

View file

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { populateItemIntel } from "@/lib/server/item-intel-service";
import type { ItemIntel } from "@/lib/types";
/**
* POST /api/v1/items/[collection]/[id]/intel/refresh
*
* collection: "quote_items" | "order_items"
* id: the PB record id
* body: { dry_run?: boolean }
*
* Runs `populateItemIntel` for one item and writes the result back
* to `ai_item_insights`. Used by:
*
* - The n8n webhook that fires on quote_item / order_item create,
* so new items get intel before the operator opens them.
* - The n8n cron that batches historical items via
* /api/v1/items/backfill-intel (see backfill-intel/route.ts).
*
* The route is gated to trusted callers (PB admin via
* `getAdminPocketBase`). For live-traffic exposure the agent key
* would be required, but n8n runs server-side and has the admin
* token already.
*
* Runtime: nodejs.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_COLLECTIONS = ["quote_items", "order_items"] as const;
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ collection: string; id: string }> },
) {
try {
const { collection, id } = await params;
if (!VALID_COLLECTIONS.includes(collection as "quote_items" | "order_items")) {
return NextResponse.json(
{ error: `Invalid collection "${collection}". Use quote_items or order_items.` },
{ status: 400 },
);
}
if (!id) {
return NextResponse.json({ error: "Missing id" }, { status: 400 });
}
const body = (await req.json().catch(() => ({}))) as { dry_run?: boolean };
const dryRun = body.dry_run === true;
// Verify the record exists (cheap check) so the caller gets a clean
// 404 instead of a confusing 500 from the backfill service.
const pb = await getAdminPocketBase();
try {
await pb.collection(collection).getOne(id);
} catch {
return NextResponse.json(
{ error: `${collection}/${id} not found` },
{ status: 404 },
);
}
if (dryRun) {
return NextResponse.json({
ok: true,
dry_run: true,
collection,
id,
// Surface a tiny preview of what would change by computing
// the intel without writing.
intel: await populateItemIntel(id, collection as "quote_items" | "order_items", {
write: false,
}),
});
}
const intel: ItemIntel = await populateItemIntel(
id,
collection as "quote_items" | "order_items",
{ write: true },
);
return NextResponse.json({ ok: true, collection, id, intel });
} catch (error) {
const message = error instanceof Error ? error.message : "Refresh failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}