feat(stock-quick-draft): API routes (search, history, add-line, delete-line)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 22:07:37 +02:00
parent 249a78a7ef
commit 0515b1ce7c
4 changed files with 241 additions and 0 deletions

View file

@ -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<string, number> = {
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<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} 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<string, unknown>)
: 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 },
);
}
}

View file

@ -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 },
);
}
}

View file

@ -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 },
);
}
}

View file

@ -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 },
);
}
}