CI / test (push) Failing after 8s
listAssetItems() now takes {page, pageSize, status, modelId, categoryId,
areaId, search} and returns {items, total} instead of the whole table --
was a bare unpaginated findMany(), shipping all ~613 rows on every page
load. Filtering moved server-side into the same WHERE clause so 'total'
reflects the filtered count, not the table's overall size -- pagination
controls stay correct regardless of which filters are active. 'search'
matches serialNumber, assetTag, or the current (non-returned) assignee's
name via the assignments relation in one query.
GET /api/assets is now the one entity route whose response is not a bare
array (see use-api-list.ts) -- its {items, total} shape needs 'total' for
page controls, so /assets fetches it directly instead of through
useApiList. Query params: page, pageSize, status, modelId, categoryId,
areaId, search.
/assets page: search is debounced 300ms (was firing a request per
keystroke otherwise), changing any filter resets to page 1, added
Previous/Next controls + "showing X-Y / total" count. Row # is now a
running index across pages, not per-page.
Separately: all 135 AssetModel rows from the 2026-08-24 real-data import
have the manufacturer name baked into 'name' already (the source file's
own Model column did, e.g. name="Bose Soundlink Revolve" manufacturer=
"Bose") -- rendering "manufacturer name" everywhere doubled it
("Bose Bose Soundlink Revolve"). Fixed at the display layer
(formatModelLabel(), skips the manufacturer prefix when name already
starts with it) rather than a data migration -- non-destructive, handles
every affected row uniformly, reversible.
Smoke-tested listAssetItems() directly (tsx): page1/page2 have zero
overlap, status filter count matches (71 in_stock), asset-tag search
finds exactly 1 row, assignee-name search correctly joins through
Assignment (4 rows for one employee), last page (13 of 613/50) has the
expected remainder of 13 rows.
tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { auth } from "@/auth";
|
|
import {
|
|
createAssetItem,
|
|
listAssetItems,
|
|
validateCreateAssetItemInput,
|
|
type AssetItemStatus,
|
|
} from "@/lib/assets";
|
|
|
|
// Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
|
|
// /api from route protection, so this checks the session itself. Flat
|
|
// permission model (CLAUDE.md): only "logged in or not", no role check.
|
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
const session = await auth();
|
|
if (!session) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
|
}
|
|
|
|
if (typeof body !== "object" || body === null) {
|
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
|
}
|
|
|
|
const {
|
|
serialNumber,
|
|
assetTag,
|
|
modelId,
|
|
purchaseDate,
|
|
warrantyEndDate,
|
|
purchaseOrderId,
|
|
cost,
|
|
costCurrency,
|
|
externalPoNumber,
|
|
areaId,
|
|
} = body as Record<string, unknown>;
|
|
const validation = validateCreateAssetItemInput({
|
|
serialNumber,
|
|
assetTag,
|
|
modelId,
|
|
purchaseDate,
|
|
warrantyEndDate,
|
|
purchaseOrderId,
|
|
cost,
|
|
costCurrency,
|
|
externalPoNumber,
|
|
areaId,
|
|
});
|
|
|
|
if (!validation.ok) {
|
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
|
}
|
|
|
|
const created = await createAssetItem(validation.value);
|
|
if (!created.ok) {
|
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
|
}
|
|
|
|
return NextResponse.json(created.value, { status: 201 });
|
|
}
|
|
|
|
// The one entity route in this app whose GET response is NOT a bare array
|
|
// (see src/hooks/use-api-list.ts's doc comment on that being the norm
|
|
// everywhere else) -- it's `{items, total}` instead, because /assets is
|
|
// paginated (2026-08-25, see listAssetItems()'s doc comment) and the
|
|
// client needs `total` to render page controls. src/app/assets/page.tsx
|
|
// fetches this directly rather than through useApiList for that reason.
|
|
export async function GET(request: Request): Promise<NextResponse> {
|
|
const session = await auth();
|
|
if (!session) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const params = url.searchParams;
|
|
const page = params.has("page") ? Number(params.get("page")) : undefined;
|
|
const pageSize = params.has("pageSize") ? Number(params.get("pageSize")) : undefined;
|
|
const status = params.get("status") ?? undefined;
|
|
const modelId = params.get("modelId") ?? undefined;
|
|
const categoryId = params.get("categoryId") ?? undefined;
|
|
const areaId = params.get("areaId") ?? undefined;
|
|
const search = params.get("search") ?? undefined;
|
|
|
|
const validStatuses = ["in_stock", "assigned", "repair", "retired", "lost"];
|
|
const result = await listAssetItems({
|
|
page,
|
|
pageSize,
|
|
status: status && validStatuses.includes(status) ? (status as AssetItemStatus) : undefined,
|
|
modelId,
|
|
categoryId,
|
|
areaId,
|
|
search,
|
|
});
|
|
return NextResponse.json(result, { status: 200 });
|
|
}
|