Files
ubi-dan-itam/src/app/api/assets/route.ts
T
toanhoang 10508f9d04
CI / test (push) Failing after 8s
Server-side pagination for /assets (50/page), fix duplicated manufacturer in model labels
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.
2026-08-24 21:31:22 -04:00

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