diff --git a/CLAUDE.md b/CLAUDE.md index bc4ad9e..1973f9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/packages/backend/src/routes/v1/accounts.test.ts b/packages/backend/src/routes/v1/accounts.test.ts index 7bb7609..4eddedc 100644 --- a/packages/backend/src/routes/v1/accounts.test.ts +++ b/packages/backend/src/routes/v1/accounts.test.ts @@ -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) }) }) diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts index 14857b2..1919fb0 100644 --- a/packages/backend/src/routes/v1/accounts.ts +++ b/packages/backend/src/routes/v1/accounts.ts @@ -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) + }) } diff --git a/packages/backend/src/routes/v1/inventory.test.ts b/packages/backend/src/routes/v1/inventory.test.ts index 6044120..b72bcda 100644 --- a/packages/backend/src/routes/v1/inventory.test.ts +++ b/packages/backend/src/routes/v1/inventory.test.ts @@ -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 () => { diff --git a/packages/backend/src/routes/v1/inventory.ts b/packages/backend/src/routes/v1/inventory.ts index 3800d1d..d3ae4b9 100644 --- a/packages/backend/src/routes/v1/inventory.ts +++ b/packages/backend/src/routes/v1/inventory.ts @@ -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) => { diff --git a/packages/backend/src/routes/v1/products.test.ts b/packages/backend/src/routes/v1/products.test.ts index 1a997fe..519c1a3 100644 --- a/packages/backend/src/routes/v1/products.test.ts +++ b/packages/backend/src/routes/v1/products.test.ts @@ -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 () => { diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts index e41f8a3..4757fc9 100644 --- a/packages/backend/src/routes/v1/products.ts +++ b/packages/backend/src/routes/v1/products.ts @@ -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) => { diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index aca6c81..278344e 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -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 = { + 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 = { + 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 = { ...input, updatedAt: new Date() } if (input.dateOfBirth) { updates.isMinor = isMinor(input.dateOfBirth) diff --git a/packages/backend/src/services/inventory.service.ts b/packages/backend/src/services/inventory.service.ts index c289f55..854ac8a 100644 --- a/packages/backend/src/services/inventory.service.ts +++ b/packages/backend/src/services/inventory.service.ts @@ -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 = { + 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 = { + 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) { diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts index 02a780b..8b58452 100644 --- a/packages/backend/src/services/product.service.ts +++ b/packages/backend/src/services/product.service.ts @@ -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 = { + 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 = { + 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( diff --git a/packages/backend/src/utils/pagination.ts b/packages/backend/src/utils/pagination.ts new file mode 100644 index 0000000..aed515d --- /dev/null +++ b/packages/backend/src/utils/pagination.ts @@ -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(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( + qb: T, + sortField: string | undefined, + order: 'asc' | 'desc', + sortableColumns: Record, + 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[] }> }, + tableName: string, + whereCondition?: SQL, +): Promise { + 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( + data: T[], + total: number, + page: number, + limit: number, +): PaginatedResponse { + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + } +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index ee5bb64..0d5f2ee 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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' diff --git a/packages/shared/src/schemas/pagination.schema.ts b/packages/shared/src/schemas/pagination.schema.ts new file mode 100644 index 0000000..89d05a0 --- /dev/null +++ b/packages/shared/src/schemas/pagination.schema.ts @@ -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 + +export interface PaginatedResponse { + data: T[] + pagination: { + page: number + limit: number + total: number + totalPages: number + } +}