Refactor all list APIs for server-side pagination, search, and sort

All list endpoints now return paginated responses:
  { data: [...], pagination: { page, limit, total, totalPages } }

Query params: ?page=1&limit=25&q=search&sort=name&order=asc

Changes:
- Added PaginationSchema in @forte/shared for consistent param parsing
- Added pagination utils (withPagination, withSort, buildSearchCondition,
  paginatedResponse) in backend
- Refactored all services: AccountService, MemberService, CategoryService,
  SupplierService, ProductService, InventoryUnitService
- Merged separate /search endpoints into list endpoints via ?q= param
- Removed AccountSearchSchema and ProductSearchSchema (replaced by
  PaginationSchema)
- Added pagination test (5 items, page 1 limit 2, expect totalPages=3)
- Updated CLAUDE.md with API conventions
- 34 tests passing
This commit is contained in:
Ryan Moon
2026-03-27 19:53:59 -05:00
parent c34ad27b86
commit 750dcf4046
13 changed files with 448 additions and 265 deletions

View File

@@ -0,0 +1,72 @@
import { sql, asc, desc, ilike, or, type SQL, type Column } from 'drizzle-orm'
import type { PgSelect } from 'drizzle-orm/pg-core'
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
/**
* Apply pagination (offset + limit) to a Drizzle query.
*/
export function withPagination<T extends PgSelect>(qb: T, page: number, limit: number) {
return qb.limit(limit).offset((page - 1) * limit)
}
/**
* Apply sorting to a Drizzle query.
* `sortableColumns` maps query param names to actual Drizzle columns.
*/
export function withSort<T extends PgSelect>(
qb: T,
sortField: string | undefined,
order: 'asc' | 'desc',
sortableColumns: Record<string, Column>,
defaultSort: Column,
) {
const column = sortField ? sortableColumns[sortField] : defaultSort
if (!column) return qb.orderBy(order === 'desc' ? desc(defaultSort) : asc(defaultSort))
return qb.orderBy(order === 'desc' ? desc(column) : asc(column))
}
/**
* Build an ilike search condition across multiple columns.
*/
export function buildSearchCondition(query: string, columns: Column[]): SQL | undefined {
if (!query || columns.length === 0) return undefined
const pattern = `%${query}%`
const conditions = columns.map((col) => ilike(col, pattern))
return conditions.length === 1 ? conditions[0] : or(...conditions)
}
/**
* Get total count for a table with optional where condition.
*/
export async function getCount(
db: { execute: (q: SQL) => Promise<{ rows: Record<string, unknown>[] }> },
tableName: string,
whereCondition?: SQL,
): Promise<number> {
const countQuery = whereCondition
? sql`SELECT count(*)::int as count FROM ${sql.identifier(tableName)} WHERE ${whereCondition}`
: sql`SELECT count(*)::int as count FROM ${sql.identifier(tableName)}`
const result = await db.execute(countQuery)
return (result.rows?.[0]?.count as number) ?? 0
}
/**
* Build a standard paginated response.
*/
export function paginatedResponse<T>(
data: T[],
total: number,
page: number,
limit: number,
): PaginatedResponse<T> {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
}
}