Remove multi-tenant company_id scoping from entire codebase

Drop company_id column from all 22 domain tables via migration.
Remove companyId from JWT payload, auth plugins, all service method
signatures (~215 occurrences), all route handlers (~105 occurrences),
test runner, test suites, and frontend auth store/types.

The company table stays as store settings (name, timezone). Tenant
isolation in a SaaS deployment would be at the database level (one
DB per customer) not the application level.

All 107 API tests pass. Zero TSC errors across all packages.
This commit is contained in:
Ryan Moon
2026-03-29 14:58:33 -05:00
parent 55f8591cf1
commit d36c6f7135
35 changed files with 353 additions and 511 deletions

View File

@@ -31,19 +31,19 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
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)
const account = await AccountService.create(app.db, parsed.data)
return reply.status(201).send(account)
})
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)
const result = await AccountService.list(app.db, params)
return reply.send(result)
})
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)
const account = await AccountService.getById(app.db, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
})
@@ -54,14 +54,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
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)
const account = await AccountService.update(app.db, 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, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.softDelete(app.db, request.companyId, id)
const account = await AccountService.softDelete(app.db, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
request.log.info({ accountId: id, userId: request.user.id }, 'Account soft-deleted')
return reply.send(account)
@@ -71,7 +71,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
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)
const result = await MemberService.list(app.db, params)
return reply.send(result)
})
@@ -83,20 +83,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
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)
const member = await MemberService.create(app.db, parsed.data)
return reply.status(201).send(member)
})
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)
const result = await MemberService.listByAccount(app.db, accountId, params)
return reply.send(result)
})
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)
const member = await MemberService.getById(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
@@ -107,7 +107,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
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)
const member = await MemberService.update(app.db, id, parsed.data)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
@@ -120,16 +120,16 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// If no accountId provided, create a new account from the member's name
if (!targetAccountId) {
const member = await MemberService.getById(app.db, request.companyId, id)
const member = await MemberService.getById(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
const account = await AccountService.create(app.db, request.companyId, {
const account = await AccountService.create(app.db, {
name: `${member.firstName} ${member.lastName}`,
billingMode: 'consolidated',
})
targetAccountId = account.id
}
const member = await MemberService.move(app.db, request.companyId, id, targetAccountId)
const member = await MemberService.move(app.db, id, targetAccountId)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
request.log.info({ memberId: id, targetAccountId, userId: request.user.id }, 'Member moved to account')
return reply.send(member)
@@ -143,14 +143,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const identifier = await MemberIdentifierService.create(app.db, request.companyId, parsed.data)
const identifier = await MemberIdentifierService.create(app.db, parsed.data)
return reply.status(201).send(identifier)
})
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const params = PaginationSchema.parse(request.query)
const result = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId, params)
const result = await MemberIdentifierService.listByMember(app.db, memberId, params)
return reply.send(result)
})
@@ -160,21 +160,21 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const identifier = await MemberIdentifierService.update(app.db, request.companyId, id, parsed.data)
const identifier = await MemberIdentifierService.update(app.db, id, parsed.data)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier)
})
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)
const identifier = await MemberIdentifierService.delete(app.db, 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, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id)
const member = await MemberService.delete(app.db, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
@@ -187,14 +187,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const link = await ProcessorLinkService.create(app.db, request.companyId, parsed.data)
const link = await ProcessorLinkService.create(app.db, parsed.data)
return reply.status(201).send(link)
})
app.get('/accounts/:accountId/processor-links', { 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 ProcessorLinkService.listByAccount(app.db, request.companyId, accountId, params)
const result = await ProcessorLinkService.listByAccount(app.db, accountId, params)
return reply.send(result)
})
@@ -204,14 +204,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const link = await ProcessorLinkService.update(app.db, request.companyId, id, parsed.data)
const link = await ProcessorLinkService.update(app.db, id, parsed.data)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
return reply.send(link)
})
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)
const link = await ProcessorLinkService.delete(app.db, id)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
return reply.send(link)
})
@@ -224,20 +224,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const method = await PaymentMethodService.create(app.db, request.companyId, parsed.data)
const method = await PaymentMethodService.create(app.db, parsed.data)
return reply.status(201).send(method)
})
app.get('/accounts/:accountId/payment-methods', { 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 PaymentMethodService.listByAccount(app.db, request.companyId, accountId, params)
const result = await PaymentMethodService.listByAccount(app.db, accountId, params)
return reply.send(result)
})
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)
const method = await PaymentMethodService.getById(app.db, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method)
})
@@ -248,14 +248,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const method = await PaymentMethodService.update(app.db, request.companyId, id, parsed.data)
const method = await PaymentMethodService.update(app.db, id, parsed.data)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method)
})
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)
const method = await PaymentMethodService.delete(app.db, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method)
})
@@ -268,20 +268,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const exemption = await TaxExemptionService.create(app.db, request.companyId, parsed.data)
const exemption = await TaxExemptionService.create(app.db, parsed.data)
return reply.status(201).send(exemption)
})
app.get('/accounts/:accountId/tax-exemptions', { 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 TaxExemptionService.listByAccount(app.db, request.companyId, accountId, params)
const result = await TaxExemptionService.listByAccount(app.db, accountId, params)
return reply.send(result)
})
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)
const exemption = await TaxExemptionService.getById(app.db, id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption)
})
@@ -292,14 +292,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const exemption = await TaxExemptionService.update(app.db, request.companyId, id, parsed.data)
const exemption = await TaxExemptionService.update(app.db, id, parsed.data)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption)
})
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)
const exemption = await TaxExemptionService.approve(app.db, id, request.user.id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
request.log.info({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id }, 'Tax exemption approved')
return reply.send(exemption)
@@ -311,7 +311,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!reason) {
return reply.status(400).send({ error: { message: 'Reason is required to revoke a tax exemption', statusCode: 400 } })
}
const exemption = await TaxExemptionService.revoke(app.db, request.companyId, id, request.user.id, reason)
const exemption = await TaxExemptionService.revoke(app.db, id, request.user.id, reason)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
request.log.warn({ exemptionId: id, accountId: exemption.accountId, userId: request.user.id, reason }, 'Tax exemption revoked')
return reply.send(exemption)

View File

@@ -3,7 +3,6 @@ import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js'
import { companies } from '../../db/schema/stores.js'
const SALT_ROUNDS = 10
@@ -27,28 +26,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
const { email, password, firstName, lastName, role } = parsed.data
const companyId = request.companyId
// Validate that the company exists
if (!companyId) {
return reply.status(400).send({
error: { message: 'Company ID is required (x-company-id header)', statusCode: 400 },
})
}
const [company] = await app.db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, companyId))
.limit(1)
if (!company) {
return reply.status(400).send({
error: { message: 'Invalid company', statusCode: 400 },
})
}
// Email is globally unique across all companies
// Email is globally unique
const existing = await app.db
.select({ id: users.id })
.from(users)
@@ -66,7 +45,6 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const [user] = await app.db
.insert(users)
.values({
companyId,
email,
passwordHash,
firstName,
@@ -84,11 +62,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const token = app.jwt.sign({
id: user.id,
companyId,
role: user.role,
})
request.log.info({ userId: user.id, email: user.email, companyId }, 'User registered')
request.log.info({ userId: user.id, email: user.email }, 'User registered')
return reply.status(201).send({ user, token })
})
@@ -126,7 +103,6 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const token = app.jwt.sign({
id: user.id,
companyId: user.companyId,
role: user.role,
})

View File

@@ -18,8 +18,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
throw new ValidationError('entityType and entityId query params required')
}
// Files are company-scoped in the service — companyId from JWT ensures access control
const fileRecords = await FileService.listByEntity(app.db, request.companyId, entityType, entityId)
const fileRecords = await FileService.listByEntity(app.db, entityType, entityId)
const data = await Promise.all(
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
)
@@ -59,7 +58,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
const buffer = await data.toBuffer()
const file = await FileService.upload(app.db, app.storage, request.companyId, {
const file = await FileService.upload(app.db, app.storage, {
data: buffer,
filename: data.filename,
contentType: data.mimetype,
@@ -76,15 +75,14 @@ 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, app.requirePermission('files.view')] }, async (request, reply) => {
const filePath = (request.params as { '*': string })['*']
if (!filePath) {
throw new ValidationError('Path required')
}
// Path traversal protection: must start with company ID, no '..' allowed
if (filePath.includes('..') || !filePath.startsWith(request.companyId)) {
// Path traversal protection: no '..' allowed
if (filePath.includes('..')) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
@@ -106,7 +104,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Get file metadata
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)
const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
const url = await app.storage.getUrl(file.path)
return reply.send({ ...file, url })
@@ -115,12 +113,12 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Generate signed URL for a file (short-lived token in query string)
app.get('/files/signed-url/: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)
const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
// Sign a short-lived token with the file path
const token = app.jwt.sign(
{ path: file.path, companyId: request.companyId, purpose: 'file-access' } as any,
{ path: file.path, purpose: 'file-access' } as any,
{ expiresIn: '15m' },
)
@@ -139,14 +137,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Verify the signed token
try {
const payload = app.jwt.verify(token) as { path: string; companyId: string; purpose: string }
const payload = app.jwt.verify(token) as { path: string; purpose: string }
if (payload.purpose !== 'file-access' || payload.path !== filePath) {
return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } })
}
// Validate company isolation — file path must start with the token's companyId
if (payload.companyId && !filePath.startsWith(payload.companyId)) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
} catch {
return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } })
}
@@ -174,7 +168,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Delete a file
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)
const file = await FileService.delete(app.db, app.storage, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
request.log.info({ fileId: id, path: file.path }, 'File deleted')
return reply.send(file)

View File

@@ -16,19 +16,19 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const category = await CategoryService.create(app.db, request.companyId, parsed.data)
const category = await CategoryService.create(app.db, parsed.data)
return reply.status(201).send(category)
})
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)
const result = await CategoryService.list(app.db, params)
return reply.send(result)
})
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)
const category = await CategoryService.getById(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
@@ -39,14 +39,14 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const category = await CategoryService.update(app.db, request.companyId, id, parsed.data)
const category = await CategoryService.update(app.db, id, parsed.data)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
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)
const category = await CategoryService.softDelete(app.db, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
@@ -58,19 +58,19 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const supplier = await SupplierService.create(app.db, request.companyId, parsed.data)
const supplier = await SupplierService.create(app.db, parsed.data)
return reply.status(201).send(supplier)
})
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)
const result = await SupplierService.list(app.db, params)
return reply.send(result)
})
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)
const supplier = await SupplierService.getById(app.db, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier)
})
@@ -81,14 +81,14 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const supplier = await SupplierService.update(app.db, request.companyId, id, parsed.data)
const supplier = await SupplierService.update(app.db, id, parsed.data)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier)
})
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)
const supplier = await SupplierService.softDelete(app.db, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier)
})

View File

@@ -6,7 +6,7 @@ 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, app.requirePermission('inventory.view')] }, async (request, reply) => {
const data = await service.list(app.db, request.companyId)
const data = await service.list(app.db)
return reply.send({ data })
})
@@ -16,12 +16,12 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
throw new ValidationError('Validation failed', parsed.error.flatten())
}
const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug)
const existing = await service.getBySlug(app.db, parsed.data.slug)
if (existing) {
throw new ConflictError(`Slug "${parsed.data.slug}" already exists`)
}
const row = await service.create(app.db, request.companyId, parsed.data)
const row = await service.create(app.db, parsed.data)
return reply.status(201).send(row)
})
@@ -31,14 +31,14 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
if (!parsed.success) {
throw new ValidationError('Validation failed', parsed.error.flatten())
}
const row = await service.update(app.db, request.companyId, id, parsed.data)
const row = await service.update(app.db, id, parsed.data)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
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)
const row = await service.delete(app.db, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})

View File

@@ -16,19 +16,19 @@ 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.create(app.db, request.companyId, parsed.data)
const product = await ProductService.create(app.db, parsed.data)
return reply.status(201).send(product)
})
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)
const result = await ProductService.list(app.db, params)
return reply.send(result)
})
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)
const product = await ProductService.getById(app.db, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
@@ -39,14 +39,14 @@ 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, request.user.id)
const product = await ProductService.update(app.db, id, parsed.data, request.user.id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
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)
const product = await ProductService.softDelete(app.db, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
@@ -59,20 +59,20 @@ 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 unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data)
const unit = await InventoryUnitService.create(app.db, parsed.data)
return reply.status(201).send(unit)
})
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)
const result = await InventoryUnitService.listByProduct(app.db, productId, params)
return reply.send(result)
})
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)
const unit = await InventoryUnitService.getById(app.db, id)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
@@ -83,7 +83,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 unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data)
const unit = await InventoryUnitService.update(app.db, id, parsed.data)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})

View File

@@ -1,5 +1,5 @@
import type { FastifyPluginAsync } from 'fastify'
import { eq, and, count, sql, type Column } from 'drizzle-orm'
import { eq, count, sql, type Column } from 'drizzle-orm'
import { PaginationSchema } from '@forte/shared/schemas'
import { RbacService } from '../../services/rbac.service.js'
import { ValidationError } from '../../lib/errors.js'
@@ -12,13 +12,12 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const baseWhere = eq(users.companyId, request.companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const where = searchCondition ?? undefined
const sortableColumns: Record<string, Column> = {
name: users.lastName,
@@ -96,7 +95,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
const [updated] = await app.db
.update(users)
.set({ isActive, updatedAt: new Date() })
.where(and(eq(users.id, userId), eq(users.companyId, request.companyId)))
.where(eq(users.id, userId))
.returning({ id: users.id, isActive: users.isActive })
if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
@@ -116,7 +115,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await RbacService.listRoles(app.db, request.companyId, params)
const result = await RbacService.listRoles(app.db, params)
return reply.send(result)
})
@@ -125,14 +124,14 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
const data = await app.db
.select()
.from(roles)
.where(and(eq(roles.companyId, request.companyId), eq(roles.isActive, true)))
.where(eq(roles.isActive, true))
.orderBy(roles.name)
return reply.send({ data })
})
app.get('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const role = await RbacService.getRoleWithPermissions(app.db, request.companyId, id)
const role = await RbacService.getRoleWithPermissions(app.db, id)
if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } })
return reply.send(role)
})
@@ -153,7 +152,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
throw new ValidationError('slug must be lowercase alphanumeric with underscores')
}
const role = await RbacService.createRole(app.db, request.companyId, {
const role = await RbacService.createRole(app.db, {
name,
slug,
description,
@@ -172,7 +171,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
permissionSlugs?: string[]
}
const role = await RbacService.updateRole(app.db, request.companyId, id, {
const role = await RbacService.updateRole(app.db, id, {
name,
description,
permissionSlugs,
@@ -185,7 +184,7 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
app.delete('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const role = await RbacService.deleteRole(app.db, request.companyId, id)
const role = await RbacService.deleteRole(app.db, id)
if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } })
request.log.info({ roleId: id, roleName: role.name, userId: request.user.id }, 'Role deleted')
return reply.send(role)

View File

@@ -23,7 +23,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const ticket = await RepairTicketService.create(app.db, request.companyId, parsed.data)
const ticket = await RepairTicketService.create(app.db, parsed.data)
return reply.status(201).send(ticket)
})
@@ -44,13 +44,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
completedDateTo: query.completedDateTo,
}
const result = await RepairTicketService.list(app.db, request.companyId, params, filters)
const result = await RepairTicketService.list(app.db, params, filters)
return reply.send(result)
})
app.get('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const ticket = await RepairTicketService.getById(app.db, request.companyId, id)
const ticket = await RepairTicketService.getById(app.db, id)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket)
})
@@ -61,7 +61,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const ticket = await RepairTicketService.update(app.db, request.companyId, id, parsed.data)
const ticket = await RepairTicketService.update(app.db, id, parsed.data)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket)
})
@@ -72,14 +72,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const ticket = await RepairTicketService.updateStatus(app.db, request.companyId, id, parsed.data.status)
const ticket = await RepairTicketService.updateStatus(app.db, id, parsed.data.status)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket)
})
app.delete('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const ticket = await RepairTicketService.delete(app.db, request.companyId, id)
const ticket = await RepairTicketService.delete(app.db, id)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
return reply.send(ticket)
})
@@ -109,14 +109,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const item = await RepairLineItemService.update(app.db, request.companyId, id, parsed.data)
const item = await RepairLineItemService.update(app.db, id, parsed.data)
if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } })
return reply.send(item)
})
app.delete('/repair-line-items/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const item = await RepairLineItemService.delete(app.db, request.companyId, id)
const item = await RepairLineItemService.delete(app.db, id)
if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } })
return reply.send(item)
})
@@ -128,19 +128,19 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const batch = await RepairBatchService.create(app.db, request.companyId, parsed.data)
const batch = await RepairBatchService.create(app.db, parsed.data)
return reply.status(201).send(batch)
})
app.get('/repair-batches', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await RepairBatchService.list(app.db, request.companyId, params)
const result = await RepairBatchService.list(app.db, params)
return reply.send(result)
})
app.get('/repair-batches/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const batch = await RepairBatchService.getById(app.db, request.companyId, id)
const batch = await RepairBatchService.getById(app.db, id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch)
})
@@ -151,7 +151,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const batch = await RepairBatchService.update(app.db, request.companyId, id, parsed.data)
const batch = await RepairBatchService.update(app.db, id, parsed.data)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch)
})
@@ -162,21 +162,21 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const batch = await RepairBatchService.updateStatus(app.db, request.companyId, id, parsed.data.status)
const batch = await RepairBatchService.updateStatus(app.db, id, parsed.data.status)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch)
})
app.post('/repair-batches/:id/approve', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const batch = await RepairBatchService.approve(app.db, request.companyId, id, request.user.id)
const batch = await RepairBatchService.approve(app.db, id, request.user.id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch)
})
app.post('/repair-batches/:id/reject', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const batch = await RepairBatchService.reject(app.db, request.companyId, id)
const batch = await RepairBatchService.reject(app.db, id)
if (!batch) return reply.status(404).send({ error: { message: 'Repair batch not found', statusCode: 404 } })
return reply.send(batch)
})
@@ -184,7 +184,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
app.get('/repair-batches/:batchId/tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const { batchId } = request.params as { batchId: string }
const params = PaginationSchema.parse(request.query)
const result = await RepairTicketService.listByBatch(app.db, request.companyId, batchId, params)
const result = await RepairTicketService.listByBatch(app.db, batchId, params)
return reply.send(result)
})
@@ -196,7 +196,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const ticket = await RepairTicketService.getById(app.db, request.companyId, ticketId)
const ticket = await RepairTicketService.getById(app.db, ticketId)
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
// Look up author name from users table
@@ -217,7 +217,7 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
app.delete('/repair-notes/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const note = await RepairNoteService.delete(app.db, request.companyId, id)
const note = await RepairNoteService.delete(app.db, id)
if (!note) return reply.status(404).send({ error: { message: 'Note not found', statusCode: 404 } })
return reply.send(note)
})
@@ -229,13 +229,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const template = await RepairServiceTemplateService.create(app.db, request.companyId, parsed.data)
const template = await RepairServiceTemplateService.create(app.db, parsed.data)
return reply.status(201).send(template)
})
app.get('/repair-service-templates', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await RepairServiceTemplateService.list(app.db, request.companyId, params)
const result = await RepairServiceTemplateService.list(app.db, params)
return reply.send(result)
})
@@ -245,14 +245,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const template = await RepairServiceTemplateService.update(app.db, request.companyId, id, parsed.data)
const template = await RepairServiceTemplateService.update(app.db, id, parsed.data)
if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } })
return reply.send(template)
})
app.delete('/repair-service-templates/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const template = await RepairServiceTemplateService.delete(app.db, request.companyId, id)
const template = await RepairServiceTemplateService.delete(app.db, id)
if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } })
return reply.send(template)
})