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 lint` — lint all packages
|
||||||
- `bun run format` — format all files with Prettier
|
- `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
|
## Conventions
|
||||||
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
|
- 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
|
- Business logic lives in `@forte/shared`, not in individual app packages
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ describe('Account routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
expect(response.statusCode).toBe(200)
|
||||||
const body = response.json()
|
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 () => {
|
it('searches by name', async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -105,14 +107,14 @@ describe('Account routes', () => {
|
|||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/accounts/search?q=johnson',
|
url: '/v1/accounts?q=johnson',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
expect(response.statusCode).toBe(200)
|
||||||
const body = response.json()
|
const body = response.json()
|
||||||
expect(body.length).toBe(1)
|
expect(body.data.length).toBe(1)
|
||||||
expect(body[0].name).toBe('Johnson Family')
|
expect(body.data[0].name).toBe('Johnson Family')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('searches by phone', async () => {
|
it('searches by phone', async () => {
|
||||||
@@ -125,12 +127,35 @@ describe('Account routes', () => {
|
|||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/accounts/search?q=867-5309',
|
url: '/v1/accounts?q=867-5309',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
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',
|
url: '/v1/accounts',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
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.statusCode).toBe(200)
|
||||||
expect(response.json().length).toBe(2)
|
expect(response.json().data.length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,145 +4,94 @@ import {
|
|||||||
AccountUpdateSchema,
|
AccountUpdateSchema,
|
||||||
MemberCreateSchema,
|
MemberCreateSchema,
|
||||||
MemberUpdateSchema,
|
MemberUpdateSchema,
|
||||||
AccountSearchSchema,
|
PaginationSchema,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
import { AccountService, MemberService } from '../../services/account.service.js'
|
import { AccountService, MemberService } from '../../services/account.service.js'
|
||||||
|
|
||||||
export const accountRoutes: FastifyPluginAsync = async (app) => {
|
export const accountRoutes: FastifyPluginAsync = async (app) => {
|
||||||
// --- Accounts ---
|
// --- Accounts ---
|
||||||
|
|
||||||
app.post(
|
app.post('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts',
|
const parsed = AccountCreateSchema.safeParse(request.body)
|
||||||
{ preHandler: [app.authenticate] },
|
if (!parsed.success) {
|
||||||
async (request, reply) => {
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
const parsed = AccountCreateSchema.safeParse(request.body)
|
}
|
||||||
if (!parsed.success) {
|
const account = await AccountService.create(app.db, request.companyId, parsed.data)
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
return reply.status(201).send(account)
|
||||||
}
|
})
|
||||||
const account = await AccountService.create(app.db, request.companyId, parsed.data)
|
|
||||||
return reply.status(201).send(account)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get(
|
app.get('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts',
|
const params = PaginationSchema.parse(request.query)
|
||||||
{ preHandler: [app.authenticate] },
|
const result = await AccountService.list(app.db, request.companyId, params)
|
||||||
async (request, reply) => {
|
return reply.send(result)
|
||||||
const accounts = await AccountService.list(app.db, request.companyId)
|
})
|
||||||
return reply.send(accounts)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get(
|
app.get('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts/search',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const account = await AccountService.getById(app.db, request.companyId, id)
|
||||||
async (request, reply) => {
|
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
|
||||||
const parsed = AccountSearchSchema.safeParse(request.query)
|
return reply.send(account)
|
||||||
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(
|
app.patch('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts/:id',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const parsed = AccountUpdateSchema.safeParse(request.body)
|
||||||
async (request, reply) => {
|
if (!parsed.success) {
|
||||||
const { id } = request.params as { id: string }
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
const account = await AccountService.getById(app.db, request.companyId, id)
|
}
|
||||||
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
|
const account = await AccountService.update(app.db, request.companyId, id, parsed.data)
|
||||||
return reply.send(account)
|
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
|
||||||
},
|
return reply.send(account)
|
||||||
)
|
})
|
||||||
|
|
||||||
app.patch(
|
app.delete('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts/:id',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const account = await AccountService.softDelete(app.db, request.companyId, id)
|
||||||
async (request, reply) => {
|
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
|
||||||
const { id } = request.params as { id: string }
|
return reply.send(account)
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Members ---
|
// --- Members ---
|
||||||
|
|
||||||
app.post(
|
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts/:accountId/members',
|
const { accountId } = request.params as { accountId: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const parsed = MemberCreateSchema.safeParse({ ...(request.body as object), accountId })
|
||||||
async (request, reply) => {
|
if (!parsed.success) {
|
||||||
const { accountId } = request.params as { accountId: string }
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
const parsed = MemberCreateSchema.safeParse({ ...request.body as object, accountId })
|
}
|
||||||
if (!parsed.success) {
|
const member = await MemberService.create(app.db, request.companyId, parsed.data)
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
return reply.status(201).send(member)
|
||||||
}
|
})
|
||||||
const member = await MemberService.create(app.db, request.companyId, parsed.data)
|
|
||||||
return reply.status(201).send(member)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get(
|
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/accounts/:accountId/members',
|
const { accountId } = request.params as { accountId: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const params = PaginationSchema.parse(request.query)
|
||||||
async (request, reply) => {
|
const result = await MemberService.listByAccount(app.db, request.companyId, accountId, params)
|
||||||
const { accountId } = request.params as { accountId: string }
|
return reply.send(result)
|
||||||
const membersList = await MemberService.listByAccount(app.db, request.companyId, accountId)
|
})
|
||||||
return reply.send(membersList)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.get(
|
app.get('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/members/:id',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const member = await MemberService.getById(app.db, request.companyId, id)
|
||||||
async (request, reply) => {
|
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
|
||||||
const { id } = request.params as { id: string }
|
return reply.send(member)
|
||||||
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(
|
app.patch('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/members/:id',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const parsed = MemberUpdateSchema.safeParse(request.body)
|
||||||
async (request, reply) => {
|
if (!parsed.success) {
|
||||||
const { id } = request.params as { id: string }
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
const parsed = MemberUpdateSchema.safeParse(request.body)
|
}
|
||||||
if (!parsed.success) {
|
const member = await MemberService.update(app.db, request.companyId, id, parsed.data)
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
|
||||||
}
|
return reply.send(member)
|
||||||
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(
|
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
'/members/:id',
|
const { id } = request.params as { id: string }
|
||||||
{ preHandler: [app.authenticate] },
|
const member = await MemberService.delete(app.db, request.companyId, id)
|
||||||
async (request, reply) => {
|
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
|
||||||
const { id } = request.params as { id: string }
|
return reply.send(member)
|
||||||
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)
|
expect(response.statusCode).toBe(200)
|
||||||
const body = response.json()
|
const body = response.json()
|
||||||
expect(body.length).toBe(2)
|
expect(body.data.length).toBe(2)
|
||||||
expect(body[0].name).toBe('Aaa First')
|
expect(body.data[0].name).toBe('Aaa First')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('soft-deletes a category', async () => {
|
it('soft-deletes a category', async () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Category routes', () => {
|
|||||||
url: '/v1/categories',
|
url: '/v1/categories',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
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({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/suppliers/search?q=ferree',
|
url: '/v1/suppliers?q=ferree',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
expect(response.statusCode).toBe(200)
|
||||||
expect(response.json().length).toBe(1)
|
expect(response.json().data.length).toBe(1)
|
||||||
expect(response.json()[0].name).toBe("Ferree's Tools")
|
expect(response.json().data[0].name).toBe("Ferree's Tools")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates a supplier', async () => {
|
it('updates a supplier', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CategoryUpdateSchema,
|
CategoryUpdateSchema,
|
||||||
SupplierCreateSchema,
|
SupplierCreateSchema,
|
||||||
SupplierUpdateSchema,
|
SupplierUpdateSchema,
|
||||||
|
PaginationSchema,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
import { CategoryService, SupplierService } from '../../services/inventory.service.js'
|
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) => {
|
app.get('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const list = await CategoryService.list(app.db, request.companyId)
|
const params = PaginationSchema.parse(request.query)
|
||||||
return reply.send(list)
|
const result = await CategoryService.list(app.db, request.companyId, params)
|
||||||
|
return reply.send(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
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) => {
|
app.get('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const list = await SupplierService.list(app.db, request.companyId)
|
const params = PaginationSchema.parse(request.query)
|
||||||
return reply.send(list)
|
const result = await SupplierService.list(app.db, request.companyId, params)
|
||||||
})
|
return reply.send(result)
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.get('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -85,24 +85,24 @@ describe('Product routes', () => {
|
|||||||
|
|
||||||
const byName = await app.inject({
|
const byName = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/products/search?q=stratocaster',
|
url: '/v1/products?q=stratocaster',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
expect(byName.json().length).toBe(1)
|
expect(byName.json().data.length).toBe(1)
|
||||||
|
|
||||||
const bySku = await app.inject({
|
const bySku = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/products/search?q=GTR-GIB',
|
url: '/v1/products?q=GTR-GIB',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
expect(bySku.json().length).toBe(1)
|
expect(bySku.json().data.length).toBe(1)
|
||||||
|
|
||||||
const byBrand = await app.inject({
|
const byBrand = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/products/search?q=fender',
|
url: '/v1/products?q=fender',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
expect(byBrand.json().length).toBe(1)
|
expect(byBrand.json().data.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs price change on update', async () => {
|
it('logs price change on update', async () => {
|
||||||
@@ -196,7 +196,7 @@ describe('Inventory unit routes', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
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 () => {
|
it('updates unit status and condition', async () => {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { FastifyPluginAsync } from 'fastify'
|
|||||||
import {
|
import {
|
||||||
ProductCreateSchema,
|
ProductCreateSchema,
|
||||||
ProductUpdateSchema,
|
ProductUpdateSchema,
|
||||||
ProductSearchSchema,
|
|
||||||
InventoryUnitCreateSchema,
|
InventoryUnitCreateSchema,
|
||||||
InventoryUnitUpdateSchema,
|
InventoryUnitUpdateSchema,
|
||||||
|
PaginationSchema,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
import { ProductService, InventoryUnitService } from '../../services/product.service.js'
|
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) => {
|
app.get('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const list = await ProductService.list(app.db, request.companyId)
|
const params = PaginationSchema.parse(request.query)
|
||||||
return reply.send(list)
|
const result = await ProductService.list(app.db, request.companyId, params)
|
||||||
})
|
return reply.send(result)
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.get('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
@@ -47,7 +39,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
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 } })
|
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
|
||||||
return reply.send(product)
|
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) => {
|
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { productId } = request.params as { productId: string }
|
const { productId } = request.params as { productId: string }
|
||||||
const units = await InventoryUnitService.listByProduct(app.db, request.companyId, productId)
|
const params = PaginationSchema.parse(request.query)
|
||||||
return reply.send(units)
|
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) => {
|
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 type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
import { accounts, members } from '../db/schema/accounts.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 { isMinor } from '@forte/shared/utils'
|
||||||
|
import {
|
||||||
|
withPagination,
|
||||||
|
withSort,
|
||||||
|
buildSearchCondition,
|
||||||
|
paginatedResponse,
|
||||||
|
} from '../utils/pagination.js'
|
||||||
|
|
||||||
export const AccountService = {
|
export const AccountService = {
|
||||||
async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) {
|
async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) {
|
||||||
@@ -52,39 +58,49 @@ export const AccountService = {
|
|||||||
return account ?? null
|
return account ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(db: PostgresJsDatabase, companyId: string, query: string) {
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
const pattern = `%${query}%`
|
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
|
||||||
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)
|
|
||||||
|
|
||||||
return results
|
const searchCondition = params.q
|
||||||
},
|
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
|
||||||
|
: undefined
|
||||||
|
|
||||||
async list(db: PostgresJsDatabase, companyId: string) {
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||||
return db
|
|
||||||
.select()
|
const sortableColumns: Record<string, typeof accounts.name> = {
|
||||||
.from(accounts)
|
name: accounts.name,
|
||||||
.where(and(eq(accounts.companyId, companyId), eq(accounts.isActive, true)))
|
email: accounts.email,
|
||||||
.limit(100)
|
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 = {
|
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 minor = input.dateOfBirth ? isMinor(input.dateOfBirth) : false
|
||||||
|
|
||||||
const [member] = await db
|
const [member] = await db
|
||||||
@@ -115,14 +131,45 @@ export const MemberService = {
|
|||||||
return member ?? null
|
return member ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
|
async listByAccount(
|
||||||
return db
|
db: PostgresJsDatabase,
|
||||||
.select()
|
companyId: string,
|
||||||
.from(members)
|
accountId: string,
|
||||||
.where(and(eq(members.companyId, companyId), eq(members.accountId, accountId)))
|
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() }
|
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||||
if (input.dateOfBirth) {
|
if (input.dateOfBirth) {
|
||||||
updates.isMinor = isMinor(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 type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
import { categories, suppliers } from '../db/schema/inventory.js'
|
import { categories, suppliers } from '../db/schema/inventory.js'
|
||||||
import type {
|
import type {
|
||||||
@@ -6,7 +6,14 @@ import type {
|
|||||||
CategoryUpdateInput,
|
CategoryUpdateInput,
|
||||||
SupplierCreateInput,
|
SupplierCreateInput,
|
||||||
SupplierUpdateInput,
|
SupplierUpdateInput,
|
||||||
|
PaginationInput,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
|
import {
|
||||||
|
withPagination,
|
||||||
|
withSort,
|
||||||
|
buildSearchCondition,
|
||||||
|
paginatedResponse,
|
||||||
|
} from '../utils/pagination.js'
|
||||||
|
|
||||||
export const CategoryService = {
|
export const CategoryService = {
|
||||||
async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) {
|
async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) {
|
||||||
@@ -26,12 +33,31 @@ export const CategoryService = {
|
|||||||
return category ?? null
|
return category ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async list(db: PostgresJsDatabase, companyId: string) {
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
return db
|
const baseWhere = and(eq(categories.companyId, companyId), eq(categories.isActive, true))
|
||||||
.select()
|
|
||||||
.from(categories)
|
const searchCondition = params.q
|
||||||
.where(and(eq(categories.companyId, companyId), eq(categories.isActive, true)))
|
? buildSearchCondition(params.q, [categories.name])
|
||||||
.orderBy(categories.sortOrder)
|
: 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) {
|
async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) {
|
||||||
@@ -71,22 +97,30 @@ export const SupplierService = {
|
|||||||
return supplier ?? null
|
return supplier ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async list(db: PostgresJsDatabase, companyId: string) {
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
return db
|
const baseWhere = and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true))
|
||||||
.select()
|
|
||||||
.from(suppliers)
|
|
||||||
.where(and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async search(db: PostgresJsDatabase, companyId: string, query: string) {
|
const searchCondition = params.q
|
||||||
const pattern = `%${query}%`
|
? buildSearchCondition(params.q, [suppliers.name, suppliers.contactName, suppliers.email])
|
||||||
return db
|
: undefined
|
||||||
.select()
|
|
||||||
.from(suppliers)
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||||
.where(
|
|
||||||
and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true), ilike(suppliers.name, pattern)),
|
const sortableColumns: Record<string, typeof suppliers.name> = {
|
||||||
)
|
name: suppliers.name,
|
||||||
.limit(50)
|
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) {
|
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 type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
|
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
|
||||||
import type {
|
import type {
|
||||||
@@ -6,7 +6,14 @@ import type {
|
|||||||
ProductUpdateInput,
|
ProductUpdateInput,
|
||||||
InventoryUnitCreateInput,
|
InventoryUnitCreateInput,
|
||||||
InventoryUnitUpdateInput,
|
InventoryUnitUpdateInput,
|
||||||
|
PaginationInput,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
|
import {
|
||||||
|
withPagination,
|
||||||
|
withSort,
|
||||||
|
buildSearchCondition,
|
||||||
|
paginatedResponse,
|
||||||
|
} from '../utils/pagination.js'
|
||||||
|
|
||||||
export const ProductService = {
|
export const ProductService = {
|
||||||
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
|
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
|
||||||
@@ -32,36 +39,42 @@ export const ProductService = {
|
|||||||
return product ?? null
|
return product ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async list(db: PostgresJsDatabase, companyId: string) {
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
return db
|
const baseWhere = and(eq(products.companyId, companyId), eq(products.isActive, true))
|
||||||
.select()
|
|
||||||
.from(products)
|
const searchCondition = params.q
|
||||||
.where(and(eq(products.companyId, companyId), eq(products.isActive, true)))
|
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])
|
||||||
.limit(100)
|
: 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) {
|
async update(
|
||||||
const pattern = `%${query}%`
|
db: PostgresJsDatabase,
|
||||||
return db
|
companyId: string,
|
||||||
.select()
|
id: string,
|
||||||
.from(products)
|
input: ProductUpdateInput,
|
||||||
.where(
|
changedBy?: string,
|
||||||
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
|
|
||||||
if (input.price !== undefined || input.minPrice !== undefined) {
|
if (input.price !== undefined || input.minPrice !== undefined) {
|
||||||
const existing = await this.getById(db, companyId, id)
|
const existing = await this.getById(db, companyId, id)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -129,13 +142,34 @@ export const InventoryUnitService = {
|
|||||||
return unit ?? null
|
return unit ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
async listByProduct(db: PostgresJsDatabase, companyId: string, productId: string) {
|
async listByProduct(
|
||||||
return db
|
db: PostgresJsDatabase,
|
||||||
.select()
|
companyId: string,
|
||||||
.from(inventoryUnits)
|
productId: string,
|
||||||
.where(
|
params: PaginationInput,
|
||||||
and(eq(inventoryUnits.companyId, companyId), eq(inventoryUnits.productId, productId)),
|
) {
|
||||||
)
|
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(
|
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 { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js'
|
||||||
export type { RegisterInput, LoginInput } 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