fix: code-review findings — closed-incident embedding 503, hardening, dedup

- /api/incidents/[id]/similar: embedding backfill on a closed incident hit
  the closure-lock trigger and turned the whole request into a 503; now
  skips persistence for closed incidents (vector still used for the query)
- addenda: cap body at 5000 chars; include body text in audit_log entry
- admin users PATCH: 404 when target user does not exist (was silent ok)
- extract shared requireAdmin to lib/auth/require-admin.ts (was duplicated
  in admin users + sites routes)
- extract escapeCsv/rowsToCsv to lib/csv.ts (was duplicated in dashboard
  export route and lib/reports/jkkp8.ts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-12 12:45:01 +08:00
co-authored by Claude Fable 5
parent e2559d1f59
commit 4242af029c
8 changed files with 50 additions and 52 deletions
+18
View File
@@ -0,0 +1,18 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { User } from '@supabase/supabase-js'
// Shared guard for /api/admin/* routes: resolves the session and requires
// the admin role. Returns user: null when the caller must respond 403.
export async function requireAdmin(): Promise<{
supabase: SupabaseClient
user: User | null
}> {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return { supabase, user: null }
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') return { supabase, user: null }
return { supabase, user }
}
+14
View File
@@ -0,0 +1,14 @@
export function escapeCsv(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function rowsToCsv(headers: string[], rows: string[][]): string {
const lines = [headers.map(escapeCsv).join(',')]
for (const row of rows) lines.push(row.map(escapeCsv).join(','))
return lines.join('\r\n')
}
+1 -9
View File
@@ -1,4 +1,5 @@
import { computeDoshObligation } from '@/lib/incidents/dosh'
import { escapeCsv } from '@/lib/csv'
export interface Jkkp8Incident {
reference_no: string | null
@@ -73,15 +74,6 @@ export const JKKP8_HEADERS = [
'Description', 'Medical Status', 'Lost Days', 'NADOPOD Obligation', 'DOSH Filing Status',
]
export function escapeCsv(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function jkkp8Csv(rows: Jkkp8Row[]): string {
const lines = [JKKP8_HEADERS.map(escapeCsv).join(',')]
for (const r of rows) {