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:
10
CLAUDE.md
10
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
72
packages/backend/src/utils/pagination.ts
Normal file
72
packages/backend/src/utils/pagination.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
20
packages/shared/src/schemas/pagination.schema.ts
Normal file
20
packages/shared/src/schemas/pagination.schema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user