Fix security and quality issues from code review
Critical: Add company scoping to line item update/delete and note delete via ownership verification through ticket join. Add companyId validation to signed URL file serving. High: Paginate notes list endpoint with search and sort support. Fix blob URL memory leaks in AuthImage components with proper cleanup on unmount. Improve photo upload error handling — count failures and show specific error count instead of silently clearing form.
This commit is contained in:
@@ -143,6 +143,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
||||
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 } })
|
||||
}
|
||||
|
||||
@@ -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, id, parsed.data)
|
||||
const item = await RepairLineItemService.update(app.db, request.companyId, 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, id)
|
||||
const item = await RepairLineItemService.delete(app.db, request.companyId, id)
|
||||
if (!item) return reply.status(404).send({ error: { message: 'Line item not found', statusCode: 404 } })
|
||||
return reply.send(item)
|
||||
})
|
||||
@@ -210,13 +210,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
app.get('/repair-tickets/:ticketId/notes', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
|
||||
const { ticketId } = request.params as { ticketId: string }
|
||||
const notes = await RepairNoteService.listByTicket(app.db, ticketId)
|
||||
return reply.send({ data: notes })
|
||||
const params = PaginationSchema.parse(request.query)
|
||||
const result = await RepairNoteService.listByTicket(app.db, ticketId, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
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, id)
|
||||
const note = await RepairNoteService.delete(app.db, request.companyId, id)
|
||||
if (!note) return reply.status(404).send({ error: { message: 'Note not found', statusCode: 404 } })
|
||||
return reply.send(note)
|
||||
})
|
||||
|
||||
@@ -254,7 +254,19 @@ export const RepairLineItemService = {
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: RepairLineItemUpdateInput) {
|
||||
async verifyOwnership(db: PostgresJsDatabase<any>, companyId: string, lineItemId: string): Promise<boolean> {
|
||||
const [item] = await db
|
||||
.select({ ticketCompanyId: repairTickets.companyId })
|
||||
.from(repairLineItems)
|
||||
.innerJoin(repairTickets, eq(repairLineItems.repairTicketId, repairTickets.id))
|
||||
.where(and(eq(repairLineItems.id, lineItemId), eq(repairTickets.companyId, companyId)))
|
||||
.limit(1)
|
||||
return !!item
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairLineItemUpdateInput) {
|
||||
if (!(await this.verifyOwnership(db, companyId, id))) return null
|
||||
|
||||
const values: Record<string, unknown> = { ...input }
|
||||
if (input.qty !== undefined) values.qty = input.qty.toString()
|
||||
if (input.unitPrice !== undefined) values.unitPrice = input.unitPrice.toString()
|
||||
@@ -269,7 +281,9 @@ export const RepairLineItemService = {
|
||||
return item ?? null
|
||||
},
|
||||
|
||||
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
|
||||
if (!(await this.verifyOwnership(db, companyId, id))) return null
|
||||
|
||||
const [item] = await db
|
||||
.delete(repairLineItems)
|
||||
.where(eq(repairLineItems.id, id))
|
||||
@@ -476,15 +490,40 @@ export const RepairNoteService = {
|
||||
return note
|
||||
},
|
||||
|
||||
async listByTicket(db: PostgresJsDatabase<any>, ticketId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(repairNotes)
|
||||
.where(eq(repairNotes.repairTicketId, ticketId))
|
||||
.orderBy(repairNotes.createdAt)
|
||||
async listByTicket(db: PostgresJsDatabase<any>, ticketId: string, params: PaginationInput) {
|
||||
const baseWhere = eq(repairNotes.repairTicketId, ticketId)
|
||||
const searchCondition = params.q
|
||||
? buildSearchCondition(params.q, [repairNotes.content, repairNotes.authorName])
|
||||
: undefined
|
||||
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
created_at: repairNotes.createdAt,
|
||||
author_name: repairNotes.authorName,
|
||||
}
|
||||
|
||||
let query = db.select().from(repairNotes).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, repairNotes.createdAt)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(repairNotes).where(where),
|
||||
])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
|
||||
// Verify note belongs to a ticket owned by this company
|
||||
const [owned] = await db
|
||||
.select({ id: repairNotes.id })
|
||||
.from(repairNotes)
|
||||
.innerJoin(repairTickets, eq(repairNotes.repairTicketId, repairTickets.id))
|
||||
.where(and(eq(repairNotes.id, id), eq(repairTickets.companyId, companyId)))
|
||||
.limit(1)
|
||||
if (!owned) return null
|
||||
|
||||
const [note] = await db
|
||||
.delete(repairNotes)
|
||||
.where(eq(repairNotes.id, id))
|
||||
|
||||
Reference in New Issue
Block a user