Files
ims/app/api/incidents/[id]/ai/triage-suggest/route.ts
T
adminandClaude Sonnet 4.6 d18d29168a feat(auth): phase 3 — replace Supabase GoTrue with bcryptjs+jose
Custom auth stack: bcryptjs password hashing (cost 10, GoTrue-compatible),
jose JWT session cookies (edge-safe, 8hr TTL), new API routes for
login/logout/reset/change-password, middleware rewritten to JWT-only
verification with no DB access. All 38 protected pages and API routes
migrated from supabase.auth.getUser() to getSession(). Supabase .from()
queries retained for Phase 4. lib/db/index.ts refactored to lazy Proxy
singleton to avoid module-level throw during Next.js build.

tsc: clean, build: clean, tests: 4/4 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 16:20:04 +08:00

128 lines
4.9 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 { createClient } from '@/lib/supabase/server'
import { getSession } from '@/lib/auth/get-session'
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 })
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)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const deepseekKey = await getApiKey(supabase, '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()
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({
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: ${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'}
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 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,
})
return NextResponse.json(input)
}