Files
ims/app/api/incidents/[id]/ai/triage-suggest/route.ts
T
adminandClaude Sonnet 4.6 98f5c4e421 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>
2026-07-23 16:57:01 +08:00

126 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/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'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// 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 [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 })
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try {
res = await client.chat.completions.create({
model: 'deepseek-chat',
max_tokens: 1024,
tools: [{
type: 'function',
function: {
name: 'suggest_triage',
description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident',
parameters: {
type: 'object',
properties: {
severity: { type: 'number', description: '1=minor, 2=low, 3=moderate, 4=serious, 5=critical/fatality' },
is_fatality: { type: 'boolean' },
is_serious_bodily_injury: { type: 'boolean', description: 'Fracture, amputation, blindness, serious burn, or similar' },
is_dangerous_occurrence: { type: 'boolean', description: 'Structural collapse, explosion, fire, scaffold collapse, etc.' },
is_occupational_disease: { type: 'boolean', description: 'Disease arising from workplace exposure' },
rationale: { type: 'string', description: 'One-sentence rationale citing NADOPOD 2004 where applicable' },
},
required: [
'severity', 'is_fatality', 'is_serious_bodily_injury',
'is_dangerous_occurrence', 'is_occupational_disease', 'rationale',
],
},
},
}],
tool_choice: { type: 'function', function: { name: 'suggest_triage' } },
messages: [{
role: 'user',
content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004.
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.`,
}],
})
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const call = res.choices[0]?.message?.tool_calls?.[0]
if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
let input: {
severity?: unknown
is_fatality?: unknown
is_serious_bodily_injury?: unknown
is_dangerous_occurrence?: unknown
is_occupational_disease?: unknown
rationale?: unknown
}
try { input = JSON.parse(call.function.arguments) }
catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) }
if (
typeof input.severity !== 'number' ||
typeof input.is_fatality !== 'boolean' ||
typeof input.is_serious_bodily_injury !== 'boolean' ||
typeof input.is_dangerous_occurrence !== 'boolean' ||
typeof input.is_occupational_disease !== 'boolean' ||
typeof input.rationale !== 'string'
) {
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
}
await withUser(session.sub, async tx => {
await writeAuditLog(tx, 'incidents', id, 'ai_triage_suggest', { suggestion: input, model: 'deepseek-chat' })
})
return NextResponse.json(input)
}