Fix security issues: path traversal, typed errors, file validation
- Fix path traversal in file serve endpoint (validate company prefix, block ..) - Add typed error classes: ValidationError, NotFoundError, ForbiddenError, ConflictError, StorageError - Global error handler catches AppError subclasses with correct status codes - 4xx logged as warn, 5xx as error with request ID - File upload validates entityType whitelist, UUID format, category pattern - Remove fragile string-matching error handling from routes - Services throw typed errors instead of plain Error - Health endpoint documented as intentionally public
This commit is contained in:
47
packages/backend/src/lib/errors.ts
Normal file
47
packages/backend/src/lib/errors.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AppError'
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
details?: unknown
|
||||||
|
|
||||||
|
constructor(message: string, details?: unknown) {
|
||||||
|
super(message, 400)
|
||||||
|
this.name = 'ValidationError'
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(entity: string) {
|
||||||
|
super(`${entity} not found`, 404)
|
||||||
|
this.name = 'NotFoundError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Insufficient permissions') {
|
||||||
|
super(message, 403)
|
||||||
|
this.name = 'ForbiddenError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 409)
|
||||||
|
this.name = 'ConflictError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 500)
|
||||||
|
this.name = 'StorageError'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,47 @@
|
|||||||
import fp from 'fastify-plugin'
|
import fp from 'fastify-plugin'
|
||||||
|
import { AppError, ValidationError } from '../lib/errors.js'
|
||||||
|
|
||||||
export const errorHandlerPlugin = fp(async (app) => {
|
export const errorHandlerPlugin = fp(async (app) => {
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
const statusCode = error.statusCode ?? 500
|
// Use AppError statusCode if available, else Fastify's, else 500
|
||||||
|
const statusCode = error instanceof AppError
|
||||||
|
? error.statusCode
|
||||||
|
: (error.statusCode ?? 500)
|
||||||
|
|
||||||
request.log.error({
|
// Only log 5xx as errors, 4xx as warnings
|
||||||
err: error,
|
const logData = {
|
||||||
|
err: statusCode >= 500 ? error : undefined,
|
||||||
statusCode,
|
statusCode,
|
||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
})
|
requestId: request.id,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
|
||||||
reply.status(statusCode).send({
|
if (statusCode >= 500) {
|
||||||
|
request.log.error(logData)
|
||||||
|
} else {
|
||||||
|
request.log.warn(logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: Record<string, unknown> = {
|
||||||
error: {
|
error: {
|
||||||
message: statusCode >= 500 ? 'Internal Server Error' : error.message,
|
message: statusCode >= 500 ? 'Internal Server Error' : error.message,
|
||||||
statusCode,
|
statusCode,
|
||||||
...(process.env.NODE_ENV === 'development' ? { stack: error.stack } : {}),
|
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Include validation details in non-production
|
||||||
|
if (error instanceof ValidationError && error.details) {
|
||||||
|
(response.error as Record<string, unknown>).details = error.details
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack trace only in development
|
||||||
|
if (process.env.NODE_ENV === 'development' && statusCode >= 500) {
|
||||||
|
(response.error as Record<string, unknown>).stack = error.stack
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(statusCode).send(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.setNotFoundHandler((request, reply) => {
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import multipart from '@fastify/multipart'
|
import multipart from '@fastify/multipart'
|
||||||
import { FileService } from '../../services/file.service.js'
|
import { FileService } from '../../services/file.service.js'
|
||||||
|
import { ValidationError } from '../../lib/errors.js'
|
||||||
|
|
||||||
export const fileRoutes: FastifyPluginAsync = async (app) => {
|
export const fileRoutes: FastifyPluginAsync = async (app) => {
|
||||||
await app.register(multipart, {
|
await app.register(multipart, {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 25 * 1024 * 1024, // 25 MB max
|
fileSize: 25 * 1024 * 1024,
|
||||||
files: 1,
|
files: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -14,11 +15,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { entityType, entityId } = request.query as { entityType?: string; entityId?: string }
|
const { entityType, entityId } = request.query as { entityType?: string; entityId?: string }
|
||||||
if (!entityType || !entityId) {
|
if (!entityType || !entityId) {
|
||||||
return reply.status(400).send({
|
throw new ValidationError('entityType and entityId query params required')
|
||||||
error: { message: 'entityType and entityId query params required', statusCode: 400 },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, request.companyId, entityType, entityId)
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
|
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
|
||||||
@@ -30,7 +30,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const data = await request.file()
|
const data = await request.file()
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return reply.status(400).send({ error: { message: 'No file provided', statusCode: 400 } })
|
throw new ValidationError('No file provided')
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityType = (data.fields.entityType as { value?: string })?.value
|
const entityType = (data.fields.entityType as { value?: string })?.value
|
||||||
@@ -38,38 +38,54 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const category = (data.fields.category as { value?: string })?.value
|
const category = (data.fields.category as { value?: string })?.value
|
||||||
|
|
||||||
if (!entityType || !entityId || !category) {
|
if (!entityType || !entityId || !category) {
|
||||||
return reply.status(400).send({
|
throw new ValidationError('entityType, entityId, and category are required')
|
||||||
error: { message: 'entityType, entityId, and category are required', statusCode: 400 },
|
}
|
||||||
})
|
|
||||||
|
// Validate entityType is a known type
|
||||||
|
const allowedEntityTypes = ['member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket']
|
||||||
|
if (!allowedEntityTypes.includes(entityType)) {
|
||||||
|
throw new ValidationError(`Invalid entityType: ${entityType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entityId format (UUID)
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(entityId)) {
|
||||||
|
throw new ValidationError('entityId must be a valid UUID')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate category (alphanumeric + underscore only)
|
||||||
|
if (!/^[a-z0-9_]+$/.test(category)) {
|
||||||
|
throw new ValidationError('category must be lowercase alphanumeric with underscores')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await data.toBuffer()
|
const buffer = await data.toBuffer()
|
||||||
|
|
||||||
try {
|
const file = await FileService.upload(app.db, app.storage, request.companyId, {
|
||||||
const file = await FileService.upload(app.db, app.storage, request.companyId, {
|
data: buffer,
|
||||||
data: buffer,
|
filename: data.filename,
|
||||||
filename: data.filename,
|
contentType: data.mimetype,
|
||||||
contentType: data.mimetype,
|
entityType,
|
||||||
entityType,
|
entityId,
|
||||||
entityId,
|
category,
|
||||||
category,
|
uploadedBy: request.user.id,
|
||||||
uploadedBy: request.user.id,
|
})
|
||||||
})
|
|
||||||
const url = await app.storage.getUrl(file.path)
|
request.log.info({ fileId: file.id, entityType, entityId, category, sizeBytes: file.sizeBytes }, 'File uploaded')
|
||||||
return reply.status(201).send({ ...file, url })
|
|
||||||
} catch (err) {
|
const url = await app.storage.getUrl(file.path)
|
||||||
if (err instanceof Error && (err.message.includes('not allowed') || err.message.includes('too large') || err.message.includes('Maximum'))) {
|
return reply.status(201).send({ ...file, url })
|
||||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serve file content (for local provider)
|
// 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] }, async (request, reply) => {
|
||||||
const filePath = (request.params as { '*': string })['*']
|
const filePath = (request.params as { '*': string })['*']
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return reply.status(400).send({ error: { message: 'Path required', statusCode: 400 } })
|
throw new ValidationError('Path required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path traversal protection: must start with company ID, no '..' allowed
|
||||||
|
if (filePath.includes('..') || !filePath.startsWith(request.companyId)) {
|
||||||
|
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -101,6 +117,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { id } = request.params as { id: string }
|
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, request.companyId, id)
|
||||||
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
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)
|
return reply.send(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
|
|||||||
import { sql } from 'drizzle-orm'
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
export const healthRoutes: FastifyPluginAsync = async (app) => {
|
export const healthRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
// Intentionally public — no auth. Load balancers, Docker health checks, and monitoring need this.
|
||||||
app.get('/health', async (request, reply) => {
|
app.get('/health', async (request, reply) => {
|
||||||
let dbStatus = 'disconnected'
|
let dbStatus = 'disconnected'
|
||||||
let redisStatus = 'disconnected'
|
let redisStatus = 'disconnected'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { LookupCreateSchema, LookupUpdateSchema } from '@forte/shared/schemas'
|
import { LookupCreateSchema, LookupUpdateSchema } from '@forte/shared/schemas'
|
||||||
import { UnitStatusService, ItemConditionService } from '../../services/lookup.service.js'
|
import { UnitStatusService, ItemConditionService } from '../../services/lookup.service.js'
|
||||||
|
import { ConflictError, ValidationError } from '../../lib/errors.js'
|
||||||
|
|
||||||
function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
|
function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
|
||||||
const routes: FastifyPluginAsync = async (app) => {
|
const routes: FastifyPluginAsync = async (app) => {
|
||||||
@@ -12,13 +13,12 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
|
|||||||
app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const parsed = LookupCreateSchema.safeParse(request.body)
|
const parsed = LookupCreateSchema.safeParse(request.body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
throw new ValidationError('Validation failed', parsed.error.flatten())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check slug uniqueness
|
|
||||||
const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug)
|
const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return reply.status(409).send({ error: { message: `Slug "${parsed.data.slug}" already exists`, statusCode: 409 } })
|
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, request.companyId, parsed.data)
|
||||||
@@ -29,32 +29,18 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
|
|||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const parsed = LookupUpdateSchema.safeParse(request.body)
|
const parsed = LookupUpdateSchema.safeParse(request.body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
throw new ValidationError('Validation failed', parsed.error.flatten())
|
||||||
}
|
|
||||||
try {
|
|
||||||
const row = await service.update(app.db, request.companyId, id, parsed.data)
|
|
||||||
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
|
|
||||||
return reply.send(row)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.message.includes('system')) {
|
|
||||||
return reply.status(403).send({ error: { message: err.message, statusCode: 403 } })
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
const row = await service.update(app.db, request.companyId, 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] }, async (request, reply) => {
|
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
try {
|
const row = await service.delete(app.db, request.companyId, id)
|
||||||
const row = await service.delete(app.db, request.companyId, id)
|
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
|
||||||
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
|
return reply.send(row)
|
||||||
return reply.send(row)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.message.includes('system')) {
|
|
||||||
return reply.status(403).send({ error: { message: err.message, statusCode: 403 } })
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return routes
|
return routes
|
||||||
|
|||||||
@@ -59,15 +59,8 @@ 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 } })
|
||||||
}
|
}
|
||||||
try {
|
const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data)
|
||||||
const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data)
|
return reply.status(201).send(unit)
|
||||||
return reply.status(201).send(unit)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) {
|
|
||||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
@@ -90,15 +83,8 @@ 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 } })
|
||||||
}
|
}
|
||||||
try {
|
const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data)
|
||||||
const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data)
|
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
|
||||||
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
|
return reply.send(unit)
|
||||||
return reply.send(unit)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) {
|
|
||||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|||||||
import { files } from '../db/schema/files.js'
|
import { files } from '../db/schema/files.js'
|
||||||
import type { StorageProvider } from '../storage/index.js'
|
import type { StorageProvider } from '../storage/index.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { ValidationError } from '../lib/errors.js'
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
const ALLOWED_PDF_TYPES = ['application/pdf']
|
const ALLOWED_PDF_TYPES = ['application/pdf']
|
||||||
@@ -38,13 +39,13 @@ export const FileService = {
|
|||||||
) {
|
) {
|
||||||
// Validate content type
|
// Validate content type
|
||||||
if (!ALLOWED_TYPES.includes(input.contentType)) {
|
if (!ALLOWED_TYPES.includes(input.contentType)) {
|
||||||
throw new Error(`File type not allowed: ${input.contentType}`)
|
throw new ValidationError(`File type not allowed: ${input.contentType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
const maxSize = ALLOWED_IMAGE_TYPES.includes(input.contentType) ? MAX_IMAGE_SIZE : MAX_PDF_SIZE
|
const maxSize = ALLOWED_IMAGE_TYPES.includes(input.contentType) ? MAX_IMAGE_SIZE : MAX_PDF_SIZE
|
||||||
if (input.data.length > maxSize) {
|
if (input.data.length > maxSize) {
|
||||||
throw new Error(`File too large: ${input.data.length} bytes (max ${maxSize})`)
|
throw new ValidationError(`File too large: ${input.data.length} bytes (max ${maxSize})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check per-entity limit
|
// Check per-entity limit
|
||||||
@@ -59,7 +60,7 @@ export const FileService = {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (existing.total >= MAX_FILES_PER_ENTITY) {
|
if (existing.total >= MAX_FILES_PER_ENTITY) {
|
||||||
throw new Error(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`)
|
throw new ValidationError(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate path
|
// Generate path
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq, and } from 'drizzle-orm'
|
import { eq, and } from 'drizzle-orm'
|
||||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
|
import { ForbiddenError } from '../lib/errors.js'
|
||||||
import {
|
import {
|
||||||
inventoryUnitStatuses,
|
inventoryUnitStatuses,
|
||||||
itemConditions,
|
itemConditions,
|
||||||
@@ -73,7 +74,7 @@ function createLookupService(
|
|||||||
|
|
||||||
if (!existing[0]) return null
|
if (!existing[0]) return null
|
||||||
if (existing[0].isSystem && input.isActive === false) {
|
if (existing[0].isSystem && input.isActive === false) {
|
||||||
throw new Error('Cannot deactivate a system status')
|
throw new ForbiddenError('Cannot deactivate a system status')
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -93,7 +94,7 @@ function createLookupService(
|
|||||||
|
|
||||||
if (!existing[0]) return null
|
if (!existing[0]) return null
|
||||||
if (existing[0].isSystem) {
|
if (existing[0].isSystem) {
|
||||||
throw new Error('Cannot delete a system status')
|
throw new ForbiddenError('Cannot delete a system status')
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq, and, count } 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 { ValidationError } from '../lib/errors.js'
|
||||||
import type {
|
import type {
|
||||||
ProductCreateInput,
|
ProductCreateInput,
|
||||||
ProductUpdateInput,
|
ProductUpdateInput,
|
||||||
@@ -119,11 +120,11 @@ export const InventoryUnitService = {
|
|||||||
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
|
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
|
||||||
if (input.condition) {
|
if (input.condition) {
|
||||||
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
||||||
if (!valid) throw new Error(`Invalid condition: "${input.condition}"`)
|
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
||||||
}
|
}
|
||||||
if (input.status) {
|
if (input.status) {
|
||||||
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
||||||
if (!valid) throw new Error(`Invalid status: "${input.status}"`)
|
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [unit] = await db
|
const [unit] = await db
|
||||||
@@ -190,11 +191,11 @@ export const InventoryUnitService = {
|
|||||||
) {
|
) {
|
||||||
if (input.condition) {
|
if (input.condition) {
|
||||||
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
|
||||||
if (!valid) throw new Error(`Invalid condition: "${input.condition}"`)
|
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
|
||||||
}
|
}
|
||||||
if (input.status) {
|
if (input.status) {
|
||||||
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
const valid = await UnitStatusService.validateSlug(db, companyId, input.status)
|
||||||
if (!valid) throw new Error(`Invalid status: "${input.status}"`)
|
if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { ...input }
|
const updates: Record<string, unknown> = { ...input }
|
||||||
|
|||||||
Reference in New Issue
Block a user