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
+1 -11
View File
@@ -1,17 +1,7 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
async function requireAdmin() {
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 }
}
import { requireAdmin } from '@/lib/auth/require-admin'
export async function POST(request: NextRequest) {
const { supabase, user } = await requireAdmin()
+2 -11
View File
@@ -1,19 +1,9 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { isValidRole } from '@/lib/auth/roles'
async function requireAdmin() {
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 }
}
import { requireAdmin } from '@/lib/auth/require-admin'
export async function GET() {
const { supabase, user } = await requireAdmin()
@@ -108,6 +98,7 @@ export async function PATCH(request: NextRequest) {
const { data: before } = await supabase
.from('users').select('role, site_id, active, department').eq('id', body.id).single()
if (!before) return NextResponse.json({ error: 'User not found' }, { status: 404 })
const { error } = await supabase.from('users').update(update).eq('id', body.id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
+1 -15
View File
@@ -2,21 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
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
}
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')
}
import { rowsToCsv } from '@/lib/csv'
export async function GET(request: NextRequest) {
const supabase = await createClient()
+3 -1
View File
@@ -41,6 +41,8 @@ export async function POST(
const body: { body?: string } = await request.json().catch(() => ({}))
const text = (body.body ?? '').trim()
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
if (text.length > 5000)
return NextResponse.json({ error: 'body must be 5000 characters or fewer' }, { status: 422 })
const { data: addendum, error } = await supabase
.from('incident_addenda')
@@ -54,7 +56,7 @@ export async function POST(
p_table_name: 'incident_addenda',
p_record_id: addendum.id,
p_action: 'INSERT',
p_new_value: { incident_id: id, author: user.id },
p_new_value: { incident_id: id, author: user.id, body: text },
})
return NextResponse.json({ id: addendum.id }, { status: 201 })
+8 -3
View File
@@ -22,12 +22,12 @@ export async function GET(
const { data: incident } = await supabase
.from('incidents')
.select('id, description, embedding')
.select('id, description, embedding, status')
.eq('id', id)
.single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const inc = incident as { id: string; description: string; embedding: string | null }
const inc = incident as { id: string; description: string; embedding: string | null; status: string }
let embeddingVec: number[]
try {
@@ -35,9 +35,14 @@ export async function GET(
embeddingVec = JSON.parse(inc.embedding) as number[]
} else {
embeddingVec = await embedText(inc.description, voyageKey)
await supabase.from('incidents').update({
// Closed incidents are locked at the DB level — the trigger would reject
// this backfill. The vector still serves the similarity query below.
if (inc.status !== 'closed') {
const { error: persistError } = await supabase.from('incidents').update({
embedding: `[${embeddingVec.join(',')}]` as unknown as string,
}).eq('id', id)
if (persistError) console.error('embedding backfill error:', persistError)
}
}
} catch {
return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 })
+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) {