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

@@ -98,7 +98,7 @@ export const repairNoteKeys = {
export function repairNoteListOptions(ticketId: string) {
return queryOptions({
queryKey: repairNoteKeys.all(ticketId),
queryFn: () => api.get<{ data: RepairNote[] }>(`/v1/repair-tickets/${ticketId}/notes`),
queryFn: () => api.get<PaginatedResponse<RepairNote>>(`/v1/repair-tickets/${ticketId}/notes`, { page: 1, limit: 100, order: 'asc' }),
enabled: !!ticketId,
})
}

View File

@@ -56,6 +56,7 @@ function AuthImage({ path, alt, className, onClick }: { path: string; alt: strin
useEffect(() => {
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${path}`, {
@@ -63,11 +64,17 @@ function AuthImage({ path, alt, className, onClick }: { path: string; alt: strin
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) setSrc(URL.createObjectURL(blob))
if (!cancelled) {
blobUrl = URL.createObjectURL(blob)
setSrc(blobUrl)
}
} catch { /* ignore */ }
}
load()
return () => { cancelled = true }
return () => {
cancelled = true
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
}, [path, token])
if (!src) return <div className={`${className} bg-muted animate-pulse`} />
@@ -110,6 +117,7 @@ export function TicketNotes({ ticketId }: TicketNotesProps) {
const note = await repairNoteMutations.create(ticketId, { content: content.trim(), visibility })
// Upload attached photos to the note
let uploadFailures = 0
for (const photo of photos) {
const formData = new FormData()
formData.append('entityType', 'repair_note')
@@ -122,16 +130,20 @@ export function TicketNotes({ ticketId }: TicketNotesProps) {
body: formData,
})
if (!uploadRes.ok) {
uploadFailures++
const err = await uploadRes.json().catch(() => ({}))
console.error('Photo upload failed:', err)
toast.error(`Photo upload failed: ${(err as any).error?.message ?? 'Unknown error'}`)
}
}
queryClient.invalidateQueries({ queryKey: repairNoteKeys.all(ticketId) })
setContent('')
setPhotos([])
toast.success('Note added')
if (uploadFailures > 0) {
toast.error(`Note added but ${uploadFailures} photo(s) failed to upload`)
} else {
toast.success(photos.length > 0 ? `Note added with ${photos.length} photo(s)` : 'Note added')
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to post note')
} finally {

View File

@@ -13,6 +13,7 @@ function AuthImage({ path, alt, className, onClick }: { path: string; alt: strin
useEffect(() => {
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${path}`, {
@@ -20,11 +21,17 @@ function AuthImage({ path, alt, className, onClick }: { path: string; alt: strin
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) setSrc(URL.createObjectURL(blob))
if (!cancelled) {
blobUrl = URL.createObjectURL(blob)
setSrc(blobUrl)
}
} catch { /* ignore */ }
}
load()
return () => { cancelled = true }
return () => {
cancelled = true
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
}, [path, token])
if (!src) return <div className={`${className} bg-muted animate-pulse`} />