import { PaginationMeta } from "../types"; /** * Parse common pagination/sort/search query params. * * SECURITY NOTE (trust boundary): `page` and `limit` are clamped here * (page ≥ 1, 1 ≤ limit ≤ 100), but `sort` and `search` are passed through * VERBATIM and are NOT validated against any schema. They originate from the * client. Callers that feed `sort` into a Prisma `orderBy` MUST allow-list it * against the set of sortable columns for that entity (e.g. * `const col = ALLOWED_SORTS.includes(sort) ? sort : "id"`), otherwise an * attacker-supplied field name reaches the query layer. `search` is only safe * when used through parameterized Prisma `contains` filters — never interpolate * it into raw SQL. */ export function parsePagination( query: Record, opts?: { defaultLimit?: number; maxLimit?: number }, ): { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; } { // Defaults suit dense list views (25/page, capped at 100). Report endpoints // that render their full result set pass a generous defaultLimit/maxLimit so // they stay effectively complete while still bounded against a runaway scan. const defaultLimit = opts?.defaultLimit ?? 25; const maxLimit = opts?.maxLimit ?? 100; const page = Math.max(1, parseInt(String(query.page || "1"), 10) || 1); const limit = Math.min( maxLimit, Math.max( 1, parseInt( String(query.limit || query.per_page || String(defaultLimit)), 10, ) || defaultLimit, ), ); const sort = String(query.sort || "id"); const order = String(query.order || "").toLowerCase() === "asc" ? "asc" : "desc"; const search = String(query.search || ""); return { page, limit, skip: (page - 1) * limit, sort, order, search }; } export function buildPaginationMeta( total: number, page: number, limit: number, ): PaginationMeta { return { page, limit, per_page: limit, total, total_pages: Math.ceil(total / limit), }; }