From 0515b1ce7c8af10bf4105d5ba12dba14ead3fd3f Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 22:07:37 +0200 Subject: [PATCH] feat(stock-quick-draft): API routes (search, history, add-line, delete-line) Co-authored-by: Orca --- .../v1/stock-quick-draft/add-line/route.ts | 104 ++++++++++++++++++ .../stock-quick-draft/line/[lineId]/route.ts | 46 ++++++++ .../api/v1/stock-quick-draft/search/route.ts | 47 ++++++++ .../supplier-history/route.ts | 44 ++++++++ 4 files changed, 241 insertions(+) create mode 100644 src/app/api/v1/stock-quick-draft/add-line/route.ts create mode 100644 src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts create mode 100644 src/app/api/v1/stock-quick-draft/search/route.ts create mode 100644 src/app/api/v1/stock-quick-draft/supplier-history/route.ts diff --git a/src/app/api/v1/stock-quick-draft/add-line/route.ts b/src/app/api/v1/stock-quick-draft/add-line/route.ts new file mode 100644 index 0000000..9923c1b --- /dev/null +++ b/src/app/api/v1/stock-quick-draft/add-line/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { addQuickDraftLine } from "@/lib/server/stock-quick-draft-service"; + +/** + * POST /api/v1/stock-quick-draft/add-line + * + * Adds a single line to a draft stock_purchase for the chosen + * supplier. The service creates the draft PO if none exists for + * the supplier. Supports inline `newStockItem` payload for the + * "+ New SKU" path. + * + * Request body: + * { + * supplierId: string, + * stockItemId?: string, + * quantity: number (>0), + * costPrice: number (>=0), + * newStockItem?: { name, sku, description?, unit? } + * } + * + * Response (success): { ok: true, purchaseId, purchaseNumber, lineId, createdDraft } + * + * Error mapping (reason → status): + * "validation" → 400 + * "supplier_not_found" → 404 + * "stock_item_not_found" → 404 + * "create_failed" → 500 + * "append_failed" → 500 + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const STATUS_BY_REASON: Record = { + validation: 400, + supplier_not_found: 404, + stock_item_not_found: 404, + create_failed: 500, + append_failed: 500, +}; + +export async function POST(req: NextRequest) { + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json( + { ok: false, reason: "validation", message: "invalid JSON body" }, + { status: 400 }, + ); + } + + const supplierId = typeof body.supplierId === "string" ? body.supplierId : ""; + const stockItemId = + typeof body.stockItemId === "string" ? body.stockItemId : undefined; + const quantity = typeof body.quantity === "number" ? body.quantity : NaN; + const costPrice = typeof body.costPrice === "number" ? body.costPrice : NaN; + const newStockItem = + body.newStockItem && typeof body.newStockItem === "object" + ? (body.newStockItem as Record) + : undefined; + + try { + const pb = await getAdminPocketBase(); + const result = await addQuickDraftLine(pb as never, { + supplierId, + stockItemId, + quantity, + costPrice, + newStockItem: + newStockItem && + typeof newStockItem.name === "string" && + typeof newStockItem.sku === "string" + ? { + name: newStockItem.name, + sku: newStockItem.sku, + description: + typeof newStockItem.description === "string" + ? newStockItem.description + : undefined, + unit: + typeof newStockItem.unit === "string" + ? newStockItem.unit + : undefined, + } + : undefined, + }); + + if (!result.ok) { + const status = STATUS_BY_REASON[result.reason] ?? 500; + return NextResponse.json(result, { status }); + } + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { + ok: false, + reason: "create_failed", + message: err instanceof Error ? err.message : "unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts b/src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts new file mode 100644 index 0000000..c0c8417 --- /dev/null +++ b/src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { removeQuickDraftLine } from "@/lib/server/stock-quick-draft-service"; + +/** + * DELETE /api/v1/stock-quick-draft/line/[lineId] + * + * Removes a `stock_purchase_items` row the modal added. Lets the + * buyer clean up mistakes without leaving the modal. + * + * Response (success): { ok: true } + * Response (not found): 404 { ok: false, reason: "line_not_found", message } + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ lineId: string }> }, +) { + const { lineId } = await ctx.params; + if (!lineId) { + return NextResponse.json( + { ok: false, reason: "validation", message: "lineId is required" }, + { status: 400 }, + ); + } + + try { + const pb = await getAdminPocketBase(); + const result = await removeQuickDraftLine(pb as never, lineId); + if (!result.ok) { + return NextResponse.json(result, { status: 404 }); + } + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { + ok: false, + reason: "delete_failed", + message: err instanceof Error ? err.message : "unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/stock-quick-draft/search/route.ts b/src/app/api/v1/stock-quick-draft/search/route.ts new file mode 100644 index 0000000..5dbbe98 --- /dev/null +++ b/src/app/api/v1/stock-quick-draft/search/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { searchStockItemsForQuickDraft } from "@/lib/server/stock-quick-draft-service"; + +/** + * GET /api/v1/stock-quick-draft/search?q=&limit= + * + * Typeahead search over `stock_items` for the quick-draft modal. + * Admin PB so the result isn't gated on the `listRule`. + * + * Query params: + * - q (required, min 2 chars) + * - limit (optional, default 10, max 25) + * + * Response: { ok: true, items: StockItemSearchResult[] } + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const q = (searchParams.get("q") ?? "").trim(); + const limitRaw = searchParams.get("limit"); + const limit = limitRaw ? Number(limitRaw) : undefined; + + if (q.length < 2) { + return NextResponse.json( + { ok: false, reason: "missing_query" }, + { status: 400 }, + ); + } + + try { + const pb = await getAdminPocketBase(); + const items = await searchStockItemsForQuickDraft(pb as never, q, limit); + return NextResponse.json({ ok: true, items }); + } catch (err) { + return NextResponse.json( + { + ok: false, + reason: "search_failed", + message: err instanceof Error ? err.message : "unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/stock-quick-draft/supplier-history/route.ts b/src/app/api/v1/stock-quick-draft/supplier-history/route.ts new file mode 100644 index 0000000..1673d0a --- /dev/null +++ b/src/app/api/v1/stock-quick-draft/supplier-history/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getStockItemSupplierHistory } from "@/lib/server/stock-quick-draft-service"; + +/** + * GET /api/v1/stock-quick-draft/supplier-history?stockItemId= + * + * Returns the per-supplier purchase history for a stock_item + * (suppliers where we've bought it from before, with last cost + + * date). Admin PB so we can read across the stock_purchase_items → + * stock_purchases → suppliers chain. + * + * Response: { ok: true, history: SupplierHistoryEntry[] } + * Returns empty array when no history. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const stockItemId = searchParams.get("stockItemId") ?? ""; + + if (!stockItemId) { + return NextResponse.json( + { ok: false, reason: "missing_stock_item_id" }, + { status: 400 }, + ); + } + + try { + const pb = await getAdminPocketBase(); + const history = await getStockItemSupplierHistory(pb as never, stockItemId); + return NextResponse.json({ ok: true, history }); + } catch (err) { + return NextResponse.json( + { + ok: false, + reason: "history_failed", + message: err instanceof Error ? err.message : "unknown error", + }, + { status: 500 }, + ); + } +}