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

@@ -39,6 +39,16 @@
- `bun run lint` — lint all packages
- `bun run format` — format all files with Prettier
## API Conventions
- All list endpoints support server-side pagination, search, and sorting via query params:
- `?page=1&limit=25` — pagination (default: page 1, 25 per page, max 100)
- `?q=search+term` — full-text search across relevant columns
- `?sort=name&order=asc` — sorting by field name, asc or desc
- List responses always return `{ data: [...], pagination: { page, limit, total, totalPages } }`
- Search and filtering is ALWAYS server-side, never client-side
- Use `PaginationSchema` from `@forte/shared/schemas` to parse query params
- Use pagination helpers from `packages/backend/src/utils/pagination.ts`
## Conventions
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
- Business logic lives in `@forte/shared`, not in individual app packages

View File

@@ -84,11 +84,13 @@ describe('Account routes', () => {
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.length).toBe(2)
expect(body.data.length).toBe(2)
expect(body.pagination.total).toBe(2)
expect(body.pagination.page).toBe(1)
})
})
describe('GET /v1/accounts/search', () => {
describe('GET /v1/accounts?q=', () => {
it('searches by name', async () => {
await app.inject({
method: 'POST',
@@ -105,14 +107,14 @@ describe('Account routes', () => {
const response = await app.inject({
method: 'GET',
url: '/v1/accounts/search?q=johnson',
url: '/v1/accounts?q=johnson',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.length).toBe(1)
expect(body[0].name).toBe('Johnson Family')
expect(body.data.length).toBe(1)
expect(body.data[0].name).toBe('Johnson Family')
})
it('searches by phone', async () => {
@@ -125,12 +127,35 @@ describe('Account routes', () => {
const response = await app.inject({
method: 'GET',
url: '/v1/accounts/search?q=867-5309',
url: '/v1/accounts?q=867-5309',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().length).toBe(1)
expect(response.json().data.length).toBe(1)
})
it('paginates results', async () => {
for (let i = 0; i < 5; i++) {
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: `Account ${i}` },
})
}
const response = await app.inject({
method: 'GET',
url: '/v1/accounts?page=1&limit=2',
headers: { authorization: `Bearer ${token}` },
})
const body = response.json()
expect(body.data.length).toBe(2)
expect(body.pagination.total).toBe(5)
expect(body.pagination.totalPages).toBe(3)
expect(body.pagination.page).toBe(1)
})
})
@@ -180,7 +205,7 @@ describe('Account routes', () => {
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
})
expect(listRes.json().length).toBe(0)
expect(listRes.json().data.length).toBe(0)
})
})
})
@@ -274,7 +299,7 @@ describe('Member routes', () => {
})
expect(response.statusCode).toBe(200)
expect(response.json().length).toBe(2)
expect(response.json().data.length).toBe(2)
})
})

View File

@@ -4,145 +4,94 @@ import {
AccountUpdateSchema,
MemberCreateSchema,
MemberUpdateSchema,
AccountSearchSchema,
PaginationSchema,
} from '@forte/shared/schemas'
import { AccountService, MemberService } from '../../services/account.service.js'
export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Accounts ---
app.post(
'/accounts',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const parsed = AccountCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const account = await AccountService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(account)
},
)
app.post('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
const parsed = AccountCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const account = await AccountService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(account)
})
app.get(
'/accounts',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const accounts = await AccountService.list(app.db, request.companyId)
return reply.send(accounts)
},
)
app.get('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await AccountService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get(
'/accounts/search',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const parsed = AccountSearchSchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } })
}
const results = await AccountService.search(app.db, request.companyId, parsed.data.q)
return reply.send(results)
},
)
app.get('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.getById(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
})
app.get(
'/accounts/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.getById(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
},
)
app.patch('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = AccountUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const account = await AccountService.update(app.db, request.companyId, id, parsed.data)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
})
app.patch(
'/accounts/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = AccountUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const account = await AccountService.update(app.db, request.companyId, id, parsed.data)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
},
)
app.delete(
'/accounts/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.softDelete(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
},
)
app.delete('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.softDelete(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
})
// --- Members ---
app.post(
'/accounts/:accountId/members',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = MemberCreateSchema.safeParse({ ...request.body as object, accountId })
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const member = await MemberService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(member)
},
)
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = MemberCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const member = await MemberService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(member)
})
app.get(
'/accounts/:accountId/members',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const membersList = await MemberService.listByAccount(app.db, request.companyId, accountId)
return reply.send(membersList)
},
)
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query)
const result = await MemberService.listByAccount(app.db, request.companyId, accountId, params)
return reply.send(result)
})
app.get(
'/members/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.getById(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
},
)
app.get('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.getById(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
app.patch(
'/members/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const member = await MemberService.update(app.db, request.companyId, id, parsed.data)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
},
)
app.patch('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const member = await MemberService.update(app.db, request.companyId, id, parsed.data)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
app.delete(
'/members/:id',
{ preHandler: [app.authenticate] },
async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
},
)
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
}

View File

@@ -81,8 +81,8 @@ describe('Category routes', () => {
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.length).toBe(2)
expect(body[0].name).toBe('Aaa First')
expect(body.data.length).toBe(2)
expect(body.data[0].name).toBe('Aaa First')
})
it('soft-deletes a category', async () => {
@@ -105,7 +105,7 @@ describe('Category routes', () => {
url: '/v1/categories',
headers: { authorization: `Bearer ${token}` },
})
expect(listRes.json().length).toBe(0)
expect(listRes.json().data.length).toBe(0)
})
})
@@ -162,13 +162,13 @@ describe('Supplier routes', () => {
const response = await app.inject({
method: 'GET',
url: '/v1/suppliers/search?q=ferree',
url: '/v1/suppliers?q=ferree',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().length).toBe(1)
expect(response.json()[0].name).toBe("Ferree's Tools")
expect(response.json().data.length).toBe(1)
expect(response.json().data[0].name).toBe("Ferree's Tools")
})
it('updates a supplier', async () => {

View File

@@ -4,6 +4,7 @@ import {
CategoryUpdateSchema,
SupplierCreateSchema,
SupplierUpdateSchema,
PaginationSchema,
} from '@forte/shared/schemas'
import { CategoryService, SupplierService } from '../../services/inventory.service.js'
@@ -20,8 +21,9 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
})
app.get('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
const list = await CategoryService.list(app.db, request.companyId)
return reply.send(list)
const params = PaginationSchema.parse(request.query)
const result = await CategoryService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
@@ -61,15 +63,9 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
})
app.get('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
const list = await SupplierService.list(app.db, request.companyId)
return reply.send(list)
})
app.get('/suppliers/search', { preHandler: [app.authenticate] }, async (request, reply) => {
const { q } = request.query as { q?: string }
if (!q) return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } })
const results = await SupplierService.search(app.db, request.companyId, q)
return reply.send(results)
const params = PaginationSchema.parse(request.query)
const result = await SupplierService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {

View File

@@ -85,24 +85,24 @@ describe('Product routes', () => {
const byName = await app.inject({
method: 'GET',
url: '/v1/products/search?q=stratocaster',
url: '/v1/products?q=stratocaster',
headers: { authorization: `Bearer ${token}` },
})
expect(byName.json().length).toBe(1)
expect(byName.json().data.length).toBe(1)
const bySku = await app.inject({
method: 'GET',
url: '/v1/products/search?q=GTR-GIB',
url: '/v1/products?q=GTR-GIB',
headers: { authorization: `Bearer ${token}` },
})
expect(bySku.json().length).toBe(1)
expect(bySku.json().data.length).toBe(1)
const byBrand = await app.inject({
method: 'GET',
url: '/v1/products/search?q=fender',
url: '/v1/products?q=fender',
headers: { authorization: `Bearer ${token}` },
})
expect(byBrand.json().length).toBe(1)
expect(byBrand.json().data.length).toBe(1)
})
it('logs price change on update', async () => {
@@ -196,7 +196,7 @@ describe('Inventory unit routes', () => {
})
expect(response.statusCode).toBe(200)
expect(response.json().length).toBe(2)
expect(response.json().data.length).toBe(2)
})
it('updates unit status and condition', async () => {

View File

@@ -2,9 +2,9 @@ import type { FastifyPluginAsync } from 'fastify'
import {
ProductCreateSchema,
ProductUpdateSchema,
ProductSearchSchema,
InventoryUnitCreateSchema,
InventoryUnitUpdateSchema,
PaginationSchema,
} from '@forte/shared/schemas'
import { ProductService, InventoryUnitService } from '../../services/product.service.js'
@@ -21,17 +21,9 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
})
app.get('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
const list = await ProductService.list(app.db, request.companyId)
return reply.send(list)
})
app.get('/products/search', { preHandler: [app.authenticate] }, async (request, reply) => {
const parsed = ProductSearchSchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } })
}
const results = await ProductService.search(app.db, request.companyId, parsed.data.q)
return reply.send(results)
const params = PaginationSchema.parse(request.query)
const result = await ProductService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
@@ -47,7 +39,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const product = await ProductService.update(app.db, request.companyId, id, parsed.data)
const product = await ProductService.update(app.db, request.companyId, id, parsed.data, request.user.id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
@@ -73,8 +65,9 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const units = await InventoryUnitService.listByProduct(app.db, request.companyId, productId)
return reply.send(units)
const params = PaginationSchema.parse(request.query)
const result = await InventoryUnitService.listByProduct(app.db, request.companyId, productId, params)
return reply.send(result)
})
app.get('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {

View File

@@ -1,8 +1,14 @@
import { eq, and, or, ilike } from 'drizzle-orm'
import { eq, and, sql, count } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { accounts, members } from '../db/schema/accounts.js'
import type { AccountCreateInput, AccountUpdateInput } from '@forte/shared/schemas'
import type { AccountCreateInput, AccountUpdateInput, PaginationInput } from '@forte/shared/schemas'
import { isMinor } from '@forte/shared/utils'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const AccountService = {
async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) {
@@ -52,39 +58,49 @@ export const AccountService = {
return account ?? null
},
async search(db: PostgresJsDatabase, companyId: string, query: string) {
const pattern = `%${query}%`
const results = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.companyId, companyId),
eq(accounts.isActive, true),
or(
ilike(accounts.name, pattern),
ilike(accounts.email, pattern),
ilike(accounts.phone, pattern),
ilike(accounts.accountNumber, pattern),
),
),
)
.limit(50)
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
return results
},
const searchCondition = params.q
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
: undefined
async list(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(accounts)
.where(and(eq(accounts.companyId, companyId), eq(accounts.isActive, true)))
.limit(100)
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof accounts.name> = {
name: accounts.name,
email: accounts.email,
created_at: accounts.createdAt,
account_number: accounts.accountNumber,
}
let query = db.select().from(accounts).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, accounts.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(accounts).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
}
export const MemberService = {
async create(db: PostgresJsDatabase, companyId: string, input: { accountId: string; firstName: string; lastName: string; dateOfBirth?: string; email?: string; phone?: string; notes?: string }) {
async create(
db: PostgresJsDatabase,
companyId: string,
input: {
accountId: string
firstName: string
lastName: string
dateOfBirth?: string
email?: string
phone?: string
notes?: string
},
) {
const minor = input.dateOfBirth ? isMinor(input.dateOfBirth) : false
const [member] = await db
@@ -115,14 +131,45 @@ export const MemberService = {
return member ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(members)
.where(and(eq(members.companyId, companyId), eq(members.accountId, accountId)))
async listByAccount(
db: PostgresJsDatabase,
companyId: string,
accountId: string,
params: PaginationInput,
) {
const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId))
const sortableColumns: Record<string, typeof members.firstName> = {
first_name: members.firstName,
last_name: members.lastName,
created_at: members.createdAt,
}
let query = db.select().from(members).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, members.lastName)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(members).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: { firstName?: string; lastName?: string; dateOfBirth?: string; email?: string; phone?: string; notes?: string }) {
async update(
db: PostgresJsDatabase,
companyId: string,
id: string,
input: {
firstName?: string
lastName?: string
dateOfBirth?: string
email?: string
phone?: string
notes?: string
},
) {
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.dateOfBirth) {
updates.isMinor = isMinor(input.dateOfBirth)

View File

@@ -1,4 +1,4 @@
import { eq, and, ilike } from 'drizzle-orm'
import { eq, and, count } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { categories, suppliers } from '../db/schema/inventory.js'
import type {
@@ -6,7 +6,14 @@ import type {
CategoryUpdateInput,
SupplierCreateInput,
SupplierUpdateInput,
PaginationInput,
} from '@forte/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const CategoryService = {
async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) {
@@ -26,12 +33,31 @@ export const CategoryService = {
return category ?? null
},
async list(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(categories)
.where(and(eq(categories.companyId, companyId), eq(categories.isActive, true)))
.orderBy(categories.sortOrder)
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(categories.companyId, companyId), eq(categories.isActive, true))
const searchCondition = params.q
? buildSearchCondition(params.q, [categories.name])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof categories.name> = {
name: categories.name,
sort_order: categories.sortOrder,
created_at: categories.createdAt,
}
let query = db.select().from(categories).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, categories.sortOrder)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(categories).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) {
@@ -71,22 +97,30 @@ export const SupplierService = {
return supplier ?? null
},
async list(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(suppliers)
.where(and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true)))
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true))
async search(db: PostgresJsDatabase, companyId: string, query: string) {
const pattern = `%${query}%`
return db
.select()
.from(suppliers)
.where(
and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true), ilike(suppliers.name, pattern)),
)
.limit(50)
const searchCondition = params.q
? buildSearchCondition(params.q, [suppliers.name, suppliers.contactName, suppliers.email])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof suppliers.name> = {
name: suppliers.name,
created_at: suppliers.createdAt,
}
let query = db.select().from(suppliers).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, suppliers.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(suppliers).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: SupplierUpdateInput) {

View File

@@ -1,4 +1,4 @@
import { eq, and, or, ilike } from 'drizzle-orm'
import { eq, and, count } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
import type {
@@ -6,7 +6,14 @@ import type {
ProductUpdateInput,
InventoryUnitCreateInput,
InventoryUnitUpdateInput,
PaginationInput,
} from '@forte/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const ProductService = {
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
@@ -32,36 +39,42 @@ export const ProductService = {
return product ?? null
},
async list(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(products)
.where(and(eq(products.companyId, companyId), eq(products.isActive, true)))
.limit(100)
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(products.companyId, companyId), eq(products.isActive, true))
const searchCondition = params.q
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof products.name> = {
name: products.name,
sku: products.sku,
brand: products.brand,
price: products.price,
created_at: products.createdAt,
}
let query = db.select().from(products).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, products.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(products).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async search(db: PostgresJsDatabase, companyId: string, query: string) {
const pattern = `%${query}%`
return db
.select()
.from(products)
.where(
and(
eq(products.companyId, companyId),
eq(products.isActive, true),
or(
ilike(products.name, pattern),
ilike(products.sku, pattern),
ilike(products.upc, pattern),
ilike(products.brand, pattern),
),
),
)
.limit(50)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProductUpdateInput, changedBy?: string) {
// Log price changes before updating
async update(
db: PostgresJsDatabase,
companyId: string,
id: string,
input: ProductUpdateInput,
changedBy?: string,
) {
if (input.price !== undefined || input.minPrice !== undefined) {
const existing = await this.getById(db, companyId, id)
if (existing) {
@@ -129,13 +142,34 @@ export const InventoryUnitService = {
return unit ?? null
},
async listByProduct(db: PostgresJsDatabase, companyId: string, productId: string) {
return db
.select()
.from(inventoryUnits)
.where(
and(eq(inventoryUnits.companyId, companyId), eq(inventoryUnits.productId, productId)),
)
async listByProduct(
db: PostgresJsDatabase,
companyId: string,
productId: string,
params: PaginationInput,
) {
const where = and(
eq(inventoryUnits.companyId, companyId),
eq(inventoryUnits.productId, productId),
)
const sortableColumns: Record<string, typeof inventoryUnits.serialNumber> = {
serial_number: inventoryUnits.serialNumber,
status: inventoryUnits.status,
condition: inventoryUnits.condition,
created_at: inventoryUnits.createdAt,
}
let query = db.select().from(inventoryUnits).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, inventoryUnits.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(inventoryUnits).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(

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

View File

@@ -1,3 +1,6 @@
export { PaginationSchema } from './pagination.schema.js'
export type { PaginationInput, PaginatedResponse } from './pagination.schema.js'
export { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js'
export type { RegisterInput, LoginInput } from './auth.schema.js'

View File

@@ -0,0 +1,20 @@
import { z } from 'zod'
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
sort: z.string().max(50).optional(),
order: z.enum(['asc', 'desc']).default('asc'),
q: z.string().max(255).optional(),
})
export type PaginationInput = z.infer<typeof PaginationSchema>
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}