Implement RBAC with permissions, roles, and route guards

- permission, role, role_permission, user_role_assignment tables
- 42 system permissions across 13 domains
- 6 default roles: Admin, Manager, Sales Associate, Technician, Instructor, Viewer
- Permission inheritance: admin implies edit implies view
- requirePermission() Fastify decorator on ALL routes
- System permissions and roles seeded per company
- Test helpers and API test runner seed RBAC data
- All 42 API tests pass with permissions enforced
This commit is contained in:
Ryan Moon
2026-03-28 17:00:42 -05:00
parent dd03fb79ef
commit 4a1fc608f0
13 changed files with 679 additions and 79 deletions

View File

@@ -26,7 +26,7 @@ import {
export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Accounts ---
app.post('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, 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 } })
@@ -35,20 +35,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(account)
})
app.get('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, 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/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, 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) => {
app.patch('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = AccountUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -59,7 +59,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(account)
})
app.delete('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, 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 } })
@@ -69,7 +69,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Members (top-level) ---
app.get('/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await MemberService.list(app.db, request.companyId, params)
return reply.send(result)
@@ -77,7 +77,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Members (scoped to account) ---
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = MemberCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -87,21 +87,21 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(member)
})
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, 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) => {
app.get('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, 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) => {
app.patch('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -112,7 +112,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(member)
})
app.post('/members/:id/move', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/members/:id/move', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { accountId } = (request.body as { accountId?: string }) ?? {}
@@ -136,7 +136,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Member Identifiers ---
app.post('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const parsed = MemberIdentifierCreateSchema.safeParse({ ...(request.body as object), memberId })
if (!parsed.success) {
@@ -146,13 +146,13 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(identifier)
})
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const identifiers = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId)
return reply.send({ data: identifiers })
})
app.patch('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberIdentifierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -163,14 +163,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(identifier)
})
app.delete('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const identifier = await MemberIdentifierService.delete(app.db, request.companyId, id)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier)
})
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, 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 } })
@@ -179,7 +179,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Processor Links ---
app.post('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = ProcessorLinkCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -189,13 +189,13 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(link)
})
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const links = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: links })
})
app.patch('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ProcessorLinkUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -206,7 +206,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(link)
})
app.delete('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const link = await ProcessorLinkService.delete(app.db, request.companyId, id)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
@@ -215,7 +215,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Payment Methods ---
app.post('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = PaymentMethodCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -225,20 +225,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(method)
})
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const methods = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: methods })
})
app.get('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const method = await PaymentMethodService.getById(app.db, request.companyId, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method)
})
app.patch('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = PaymentMethodUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -249,7 +249,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(method)
})
app.delete('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const method = await PaymentMethodService.delete(app.db, request.companyId, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
@@ -258,7 +258,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Tax Exemptions ---
app.post('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = TaxExemptionCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -268,20 +268,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(exemption)
})
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const exemptions = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: exemptions })
})
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.getById(app.db, request.companyId, id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption)
})
app.patch('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = TaxExemptionUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -292,7 +292,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(exemption)
})
app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.approve(app.db, request.companyId, id, request.user.id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
@@ -300,7 +300,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(exemption)
})
app.post('/tax-exemptions/:id/revoke', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/tax-exemptions/:id/revoke', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { reason } = (request.body as { reason?: string }) ?? {}
if (!reason) {

View File

@@ -12,7 +12,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// List files for an entity
app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { entityType, entityId } = request.query as { entityType?: string; entityId?: string }
if (!entityType || !entityId) {
throw new ValidationError('entityType and entityId query params required')
@@ -27,7 +27,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Upload a file
app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => {
const data = await request.file()
if (!data) {
throw new ValidationError('No file provided')
@@ -77,7 +77,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Serve file content (for local provider)
// Path traversal protection: validate the path starts with the requesting company's ID
app.get('/files/serve/*', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/files/serve/*', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const filePath = (request.params as { '*': string })['*']
if (!filePath) {
throw new ValidationError('Path required')
@@ -104,7 +104,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Get file metadata
app.get('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
@@ -113,7 +113,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Delete a file
app.delete('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.delete(app.db, app.storage, request.companyId, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })

View File

@@ -11,7 +11,7 @@ import { CategoryService, SupplierService } from '../../services/inventory.servi
export const inventoryRoutes: FastifyPluginAsync = async (app) => {
// --- Categories ---
app.post('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = CategoryCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -20,20 +20,20 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(category)
})
app.get('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
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) => {
app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, request.companyId, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
app.patch('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = CategoryUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -44,7 +44,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(category)
})
app.delete('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await CategoryService.softDelete(app.db, request.companyId, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
@@ -53,7 +53,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
// --- Suppliers ---
app.post('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = SupplierCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -62,20 +62,20 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(supplier)
})
app.get('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
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) => {
app.get('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const supplier = await SupplierService.getById(app.db, request.companyId, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier)
})
app.patch('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = SupplierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -86,7 +86,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(supplier)
})
app.delete('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const supplier = await SupplierService.softDelete(app.db, request.companyId, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })

View File

@@ -5,12 +5,12 @@ import { ConflictError, ValidationError } from '../../lib/errors.js'
function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
const routes: FastifyPluginAsync = async (app) => {
app.get(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.get(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const data = await service.list(app.db, request.companyId)
return reply.send({ data })
})
app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.post(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const parsed = LookupCreateSchema.safeParse(request.body)
if (!parsed.success) {
throw new ValidationError('Validation failed', parsed.error.flatten())
@@ -25,7 +25,7 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
return reply.status(201).send(row)
})
app.patch(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LookupUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -36,7 +36,7 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
return reply.send(row)
})
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const row = await service.delete(app.db, request.companyId, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })

View File

@@ -11,7 +11,7 @@ import { ProductService, InventoryUnitService } from '../../services/product.ser
export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Products ---
app.post('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = ProductCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -20,20 +20,20 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(product)
})
app.get('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
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) => {
app.get('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.getById(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
app.patch('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ProductUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -44,7 +44,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.send(product)
})
app.delete('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.softDelete(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
@@ -53,7 +53,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Inventory Units ---
app.post('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = InventoryUnitCreateSchema.safeParse({ ...(request.body as object), productId })
if (!parsed.success) {
@@ -63,21 +63,21 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(unit)
})
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
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) => {
app.get('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const unit = await InventoryUnitService.getById(app.db, request.companyId, id)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
app.patch('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = InventoryUnitUpdateSchema.safeParse(request.body)
if (!parsed.success) {