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:
Ryan Moon
2026-03-29 12:16:17 -05:00
parent 21ef7e7059
commit 72d0ff0a33
7 changed files with 89 additions and 24 deletions

View File

@@ -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))