feat(db): phase 4 group 3 — incident routes to Drizzle

Convert all 11 incident API routes from Supabase PostgREST to Drizzle
ORM with withUser/asAdmin/writeAuditLog patterns and RLS enforcement.
Only uploadEvidenceFile retains supabase client (Phase 5 storage work).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:57:01 +08:00
co-authored by Claude Sonnet 4.6
parent d234ebf916
commit 98f5c4e421
11 changed files with 482 additions and 431 deletions
+47 -49
View File
@@ -1,8 +1,11 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { withUser, asAdmin } from '@/lib/db/with-user'
import { writeAuditLog } from '@/lib/db/audit'
import { incidents, auditLog, sites, zones } from '@/lib/db/schema'
import { eq, and, gte, sql } from 'drizzle-orm'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
@@ -16,45 +19,43 @@ export async function POST(
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', session.sub)
.eq('action', 'ai_rca_draft')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
// Rate limit
const since = new Date(Date.now() - 60_000)
const [rateRow] = await asAdmin(db =>
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
.where(and(
eq(auditLog.changedBy, session.sub),
eq(auditLog.action, 'ai_rca_draft'),
gte(auditLog.changedAt, since),
))
)
if (Number(rateRow?.cnt ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
const client = createDeepSeekClient(deepseekKey)
const { data: incident } = await supabase
.from('incidents')
.select(`
id, incident_type, description, severity, injury_involved, medical_status,
is_fatality, is_serious_bodily_injury, triage_notes,
sites (name), zones (name)
`)
.eq('id', id)
.single()
const [incident] = await withUser(session.sub, async tx =>
tx.select({
incidentType: incidents.incidentType,
description: incidents.description,
severity: incidents.severity,
injuryInvolved: incidents.injuryInvolved,
medicalStatus: incidents.medicalStatus,
isFatality: incidents.isFatality,
isSeriousBodilyInjury: incidents.isSeriousBodilyInjury,
triageNotes: incidents.triageNotes,
siteName: sites.name,
zoneName: zones.name,
})
.from(incidents)
.leftJoin(sites, eq(incidents.siteId, sites.id))
.leftJoin(zones, eq(incidents.zoneId, zones.id))
.where(eq(incidents.id, id))
.limit(1)
)
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const inc = incident as {
incident_type: string
description: string
severity: number | null
injury_involved: boolean
medical_status: string | null
is_fatality: boolean
is_serious_bodily_injury: boolean
triage_notes: string | null
}
const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown'
const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown'
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try {
res = await client.chat.completions.create({
@@ -99,15 +100,15 @@ export async function POST(
role: 'user',
content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for this incident.
Site: ${siteName}
Zone: ${zoneName}
Incident type: ${inc.incident_type}
Description: ${inc.description}
Severity: ${inc.severity ?? 'not yet assigned'}/5
Injury involved: ${inc.injury_involved ? `yes — ${inc.medical_status}` : 'no'}
Fatality: ${inc.is_fatality ? 'yes' : 'no'}
Serious bodily injury: ${inc.is_serious_bodily_injury ? 'yes' : 'no'}
Triage notes: ${inc.triage_notes ?? 'none'}
Site: ${incident.siteName ?? 'Unknown'}
Zone: ${incident.zoneName ?? 'Unknown'}
Incident type: ${incident.incidentType}
Description: ${incident.description}
Severity: ${incident.severity ?? 'not yet assigned'}/5
Injury involved: ${incident.injuryInvolved ? `yes — ${incident.medicalStatus}` : 'no'}
Fatality: ${incident.isFatality ? 'yes' : 'no'}
Serious bodily injury: ${incident.isSeriousBodilyInjury ? 'yes' : 'no'}
Triage notes: ${incident.triageNotes ?? 'none'}
Provide 35 Why steps drilling from immediate cause to root cause. Give a one-sentence root cause statement. Suggest 3 corrective/preventive actions appropriate for a Malaysian warehouse context.`,
}],
@@ -131,14 +132,11 @@ Provide 35 Why steps drilling from immediate cause to root cause. Give a one-
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
}
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'ai_rca_draft',
p_new_value: {
root_cause_summary: draft.root_cause_summary,
await withUser(session.sub, async tx => {
await writeAuditLog(tx, 'incidents', id, 'ai_rca_draft', {
root_cause_summary: draft.root_cause_summary as string,
model: 'deepseek-chat',
} as never,
})
})
return NextResponse.json(draft)
@@ -1,8 +1,11 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
import { withUser, asAdmin } from '@/lib/db/with-user'
import { writeAuditLog } from '@/lib/db/audit'
import { incidents, auditLog } from '@/lib/db/schema'
import { eq, and, gte, sql } from 'drizzle-orm'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
@@ -16,36 +19,34 @@ export async function POST(
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const supabase = await createClient()
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', session.sub)
.eq('action', 'ai_triage_suggest')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
// Rate limit
const since = new Date(Date.now() - 60_000)
const [rateRow] = await asAdmin(db =>
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
.where(and(
eq(auditLog.changedBy, session.sub),
eq(auditLog.action, 'ai_triage_suggest'),
gte(auditLog.changedAt, since),
))
)
if (Number(rateRow?.cnt ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
const client = createDeepSeekClient(deepseekKey)
const { data: incident } = await supabase
.from('incidents')
.select('id, incident_type, description, injury_involved, asset_involved, medical_status')
.eq('id', id)
.single()
const [incident] = await withUser(session.sub, async tx =>
tx.select({
incidentType: incidents.incidentType,
description: incidents.description,
injuryInvolved: incidents.injuryInvolved,
assetInvolved: incidents.assetInvolved,
medicalStatus: incidents.medicalStatus,
})
.from(incidents).where(eq(incidents.id, id)).limit(1)
)
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const inc = incident as {
incident_type: string
description: string
injury_involved: boolean
asset_involved: boolean
medical_status: string | null
}
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try {
res = await client.chat.completions.create({
@@ -78,11 +79,11 @@ export async function POST(
role: 'user',
content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004.
Incident type: ${inc.incident_type}
Description: ${inc.description}
Injury involved: ${inc.injury_involved ? 'yes' : 'no'}
Medical status: ${inc.medical_status ?? 'N/A'}
Asset/equipment involved: ${inc.asset_involved ? 'yes' : 'no'}
Incident type: ${incident.incidentType}
Description: ${incident.description}
Injury involved: ${incident.injuryInvolved ? 'yes' : 'no'}
Medical status: ${incident.medicalStatus ?? 'N/A'}
Asset/equipment involved: ${incident.assetInvolved ? 'yes' : 'no'}
Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`,
}],
@@ -116,11 +117,8 @@ Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
}
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'ai_triage_suggest',
p_new_value: { suggestion: input, model: 'deepseek-chat' } as never,
await withUser(session.sub, async tx => {
await writeAuditLog(tx, 'incidents', id, 'ai_triage_suggest', { suggestion: input, model: 'deepseek-chat' })
})
return NextResponse.json(input)